Compare commits

...

66 Commits

Author SHA1 Message Date
a4926c1368 fix: update regex for blob URL matching and enhance display name input in comments 2026-05-12 22:26:37 +10:00
b09a329d62 Refactor code structure for improved readability and maintainability 2026-05-12 04:43:25 +10:00
655d16ea6b fix: add line break for improved formatting in welcome blog post 2026-05-11 18:51:59 +10:00
cf08709151 fix: remove unnecessary comma in welcome blog post introduction 2026-05-11 18:51:11 +10:00
37d6388244 fix: correct scroll behavior for top bar visibility toggle 2026-05-11 18:50:12 +10:00
13fa54401f feat: add RSS feed link to blog footer and update blog page background color 2026-05-11 10:32:30 +10:00
ffe6ab9f90 fix: adjust grid color opacity for improved visibility in workspace 2026-05-11 10:28:29 +10:00
7091b6844e feat: enhance blog layout with footer and improved navigation; add SVG icons for GitHub and Bluesky 2026-05-11 10:23:16 +10:00
0834d30e9b fix: add alt text to image in welcome blog post for improved accessibility 2026-05-11 01:24:06 +10:00
52c196515e Refactor code structure for improved readability and maintainability 2026-05-11 01:20:44 +10:00
19a3b06682 fix: update image reference in welcome blog post 2026-05-11 01:18:23 +10:00
868381c9c8 fix: update title casing and change tag from 'welcome' to 'announcement' 2026-05-11 01:16:28 +10:00
16f72f58ce feat: restructure welcome blog post and update content for clarity 2026-05-11 01:13:52 +10:00
e165203a8b fix: correct date and update image reference in markdown test 2026-05-11 00:00:46 +10:00
770cc37171 feat: update page title and Open Graph metadata for consistency 2026-05-10 23:56:17 +10:00
45774f4d4d feat: add sitemap generation to build process 2026-05-10 23:51:09 +10:00
bfa7cfcb36 feat: increase description length limit and add default Open Graph image 2026-05-10 23:45:28 +10:00
d990adf193 feat: add home link to navigation 2026-05-10 23:41:46 +10:00
6e1728d35e feat: add RSS feed generation and integrate RSS link in blog pages 2026-05-10 23:37:20 +10:00
7007660a80 feat: add extractDescription method and enhance renderPageTemplate with Open Graph metadata 2026-05-10 23:33:29 +10:00
ba36cdd05b refactor: remove blog and markdown test pages, update navigation links
- Deleted blog.html and associated blog post files (markdown-test, testddd, node-graphs, welcome).
- Updated index.html to use dynamic navigation links from navLinks.js.
- Enhanced MarkdownRenderer to include new heading pin elements.
- Implemented SVG bezier splines for blog post navigation.
- Adjusted CSS styles for blog layout and new elements.
2026-05-10 23:29:02 +10:00
fabbb0539a fix: adjust margin-bottom for blog logo link to improve layout 2026-05-10 20:37:40 +10:00
daef942927 feat: implement scroll top bar and enhance blog navigation structure 2026-05-10 20:21:57 +10:00
a16445dd78 feat: enhance blog functionality with markdown rendering and bundling
- Updated build process to bundle main and blog scripts separately using esbuild.
- Modified HTML files to reference new bundled JavaScript files for improved performance.
- Implemented a new blogPage.js script to handle markdown rendering and copy functionality.
- Replaced inline markdown rendering with JSON payloads for better separation of content and presentation.
- Added Prism.js for syntax highlighting in blog posts.
- Improved CSS styles for better layout and scrolling behavior on blog pages.
2026-05-10 19:53:19 +10:00
35b049b833 Add static blog generator and initial blog posts
- Implement StaticBlogGenerator class for generating static HTML blog pages from markdown data.
- Create initial blog index page (blog.html) with links to posts.
- Add individual blog post pages for "Markdown Formatting Test", "Building Interactive Node Graphs", "Welcome to My Blog", "test d d d d", and "testddd".
- Include markdown rendering capabilities for formatting content in blog posts.
2026-05-10 19:46:31 +10:00
b8e357e76b fix: Correct image width in markdown test for accurate display 2026-05-10 19:34:38 +10:00
924ebfa164 chore: update code structure for improved readability and maintainability 2026-05-10 19:34:06 +10:00
ed797df9b4 fix: Update image links in markdown test for correct rendering 2026-05-10 19:28:32 +10:00
d65cadc2e5 feat: Implement intrinsic image scaling in Markdown renderer 2026-05-10 19:27:16 +10:00
01bac4c0bd fix: Update URL sanitization logic in Markdown renderer 2026-05-10 19:24:14 +10:00
4e4665c4ca Refactor code structure for improved readability and maintainability 2026-05-10 19:23:56 +10:00
628adc59ee fix: Update image dimensions in markdown test and enhance URL sanitization 2026-05-10 19:23:01 +10:00
f56a1e9181 feat: Add table and line break support in Markdown renderer 2026-05-10 19:20:27 +10:00
2625ecc55b feat: Enhance image handling in Markdown with metadata parsing 2026-05-10 19:19:23 +10:00
715bf07eae Add new image asset for blog post media
- Introduced a new PNG image file located at `website/data/blog/media/paste-1778404389025-wzt8y.png`.
- The image includes metadata related to its creation and editing history.
2026-05-10 19:13:21 +10:00
a9b65b1dba fix: Add caption to image in markdown test file 2026-05-10 19:11:25 +10:00
0f59de54bb blog post editor 2026-05-10 19:09:26 +10:00
40fd8e8e42 feat: Add blog post functionality with Markdown support
- Introduced BlogPostNode to render blog posts with metadata and content.
- Updated graph.json to include new blog post nodes.
- Enhanced MarkdownRenderer to support code block options for max height.
- Added CSS styles for improved code block scrolling and syntax highlighting.
- Created example blog posts: "Markdown Formatting Test", "Welcome to My Blog", and "Building Interactive Node Graphs".
- Implemented dynamic loading of blog data from blogs.json.
- Updated WorkspaceNavigator to allow smooth panning over blog nodes.
2026-04-19 09:09:53 +10:00
34c2aa4316 can now add nodes 2026-04-19 05:45:33 +10:00
7e7e6f2c22 update to node site 2026-04-15 05:02:45 +10:00
3c45406f6f feat: add donate command to support the developer 2026-04-04 19:10:33 +11:00
f40856d747 fix: update presence fetch to correct Matrix user identifier 2026-04-04 17:40:15 +11:00
17e52681d7 update chat address 2026-03-15 21:56:02 +11:00
d762f8e67e feat: add game command functionality and update quick commands for Number Match game 2026-03-15 12:39:28 +11:00
df10268a83 feat: add MxjsClient for lightweight Matrix client functionality
- Implemented core methods for user authentication (register, login, logout).
- Added room management features (createRoom, joinRoom, leaveRoom, inviteUser).
- Included message handling capabilities (sendMessage, editMessage, redactEvent).
- Introduced user moderation actions (kickUser, banUser, unbanUser).
- Implemented profile management (getProfile, setDisplayName, setAvatarUrl).
- Added support for media uploads and fetching messages from room timelines.
- Included event handling with emit and on methods for custom event listeners.
- Sanitization of HTML content and mention handling in messages.
2026-03-15 12:27:23 +11:00
6d7ee1db71 feat: add /samsay command to utilize SAM speech synthesizer for text-to-speech functionality 2026-03-15 10:19:33 +11:00
1f658c179f feat: add skip functionality to boot animation and update chat room alias 2026-03-14 23:55:18 +11:00
5a782e46f8 Condrat ulationz 2026-03-14 23:07:19 +11:00
6c381e8bbc feat: integrate SAM speech synthesizer for Number Match game feedback 2026-03-14 22:25:05 +11:00
3ae550bb76 feat: add Number Match game command for terminal-based number matching puzzle 2026-03-14 20:07:38 +11:00
6a66d9c097 clickable chat 2026-03-07 12:53:27 +11:00
9b9ab8593f call to action for chat 2026-03-07 12:51:25 +11:00
6529eb2e6b neo update, bridge too 2026-03-07 12:49:27 +11:00
7d9a182b4d added bridge 2026-03-07 12:38:53 +11:00
864b1e3ecc update the message autocomplete + change up logo 2026-03-07 11:51:03 +11:00
628a0cb948 Added sounds + mentions 2026-03-07 09:52:42 +11:00
3d987dced1 Update favicon paths and enhance chat functionality with public message fetching 2026-03-07 09:16:57 +11:00
288b48ddab Add terminal commands for enhanced functionality
- Implemented 'about' command to provide information about the terminal.
- Created 'banner' command to display a welcome message based on terminal width.
- Added 'bluesky' command to fetch and display recent posts from Bluesky.
- Introduced 'clear' command to clear the terminal screen.
- Developed 'color' command to change the terminal's color scheme.
- Added 'contact' command to display contact information.
- Implemented 'date' command to show the current date and time.
- Created 'echo' command to echo back user messages.
- Added 'github' command to simulate opening the GitHub repository.
- Implemented 'help' command to list available commands.
- Developed 'history' command to show command history.
- Added 'privacy' command to display the privacy policy.
- Implemented 'whoami' command to show user information.
2026-03-07 06:22:10 +11:00
bf283e8425 Enhance mobile responsiveness: Add styles for very skinny screens and implement mobile keyboard detection 2026-03-05 19:35:37 +11:00
da4f07b8fe Update icons and enhance chat command functionality in terminal 2026-03-05 19:30:22 +11:00
4f5664a633 Add new logo image in 64px resolution to the website 2026-03-05 19:26:28 +11:00
fb86ebbd0d Implement BIOS boot animation and enhance terminal interactivity with clickable commands 2026-03-05 19:20:07 +11:00
134e917cac Enhance responsive design: Adjust container positioning and border styles for smaller screens 2026-03-05 02:51:32 +11:00
b79cbf0545 Refactor code structure for improved readability and maintainability 2026-03-05 02:33:35 +11:00
7f5ae16dea Add build script for Cloudflare Pages deployment 2026-03-04 23:38:08 +11:00
4be791f90e Redesign: Replace site with interactive terminal interface
- New xterm.js-based terminal with retro CRT aesthetics
- Matrix chat integration with real-time messaging
- Bluesky social media integration
- Interactive commands (help, about, chat, bluesky, privacy, etc.)
- Responsive design with mobile support
- Privacy policy command
- Quick navigation links (Docs, GitHub, Bluesky)
- Preserved all image assets (blocks, items, particles, icons)
- Updated README and package.json with new features
2026-03-04 23:15:44 +11:00
726 changed files with 24136 additions and 6959 deletions

131
.gitignore vendored
View File

@@ -1,119 +1,34 @@
# Logs
logs
*.log
# Node modules
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# OS files
.DS_Store
Thumbs.db
*.swp
*.swo
*~
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# IDE
.vscode/
.idea/
*.sublime-project
*.sublime-workspace
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Build outputs
dist/
build/
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
# Environment variables
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache files
.cache
.parcel-cache
# Logs
*.log
logs/
# Next.js build output
.next
out
# Nuxt.js build output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp files
.temp
# Docusaurus cache and generated files
.docusaurus
# SvelteKit build output
.svelte-kit
build
# Remix build output
.cache
build/
public/build/
# Vitest coverage directory
coverage/
# Temporary files
*.tmp
temp/

View File

@@ -1,3 +0,0 @@
{
"liveServer.settings.port": 5502
}

123
README.md
View File

@@ -1,5 +1,122 @@
# Litruv
# lit.ruv.wtf
Check out the live site: **[lit.ruv.wtf](https://lit.ruv.wtf)**
Interactive terminal interface for lit.ruv.wtf - A retro-styled command line experience with chat, documentation, and more.
Dev/Tech Artist at MatesMedia. Follow for coding, game development, and creative projects.
## Features
- **Classic CRT Aesthetics**: Green phosphor terminal with authentic CRT effects including scanlines and subtle flicker
- **Interactive Chat**: Matrix-powered chat integration with real-time messaging
- **Keyboard Navigation**: Full keyboard support with arrow keys, history navigation, and standard terminal shortcuts
- **Mouse Support**: Click to position cursor, scroll through history, and interact with links
- **Responsive Design**: Works seamlessly on desktop, tablet, and mobile devices
- **Multiple Color Schemes**: Switch between green, amber, blue, and white phosphor themes
- **Command History**: Navigate through previous commands using up/down arrows
- **Bluesky Integration**: Fetch and display recent posts from Bluesky
- **Quick Links**: Easy access to documentation, GitHub, and social media
## Getting Started
### Quick Start
1. Open `index.html` directly in your browser, or
2. Serve with a local web server:
```bash
# Using Python 3
python3 -m http.server 8000
# Or using npm script
npm start
# Or using Node.js http-server
npx http-server -p 8000
```
3. Open `http://localhost:8000` in your browser
## Available Commands
- `help` - Display available commands
- `about` - Information about this terminal
- `clear` - Clear the terminal screen
- `echo [message]` - Echo back your message
- `date` - Display current date and time
- `whoami` - Display current user information
- `history` - Show command history
- `color [scheme]` - Change terminal color scheme (green, amber, blue, white)
- `banner` - Display welcome banner
- `bluesky [count]` - Fetch recent posts from Bluesky (default: 5)
- `chat` - Enter interactive chat (type /quit to exit)
- `github` - Visit GitHub repository
- `contact` - Display contact information
- `privacy` - Display privacy policy
## Chat Commands
While in chat mode (after running `chat`):
- `/help` - Show chat commands
- `/nick [name]` - Change your display name
- `/quit` or `/exit` - Exit chat mode
## Keyboard Shortcuts
- `↑/↓` - Navigate command history
- `←/→` - Move cursor within current line
- `Home` - Move cursor to start of line
- `End` - Move cursor to end of line
- `Ctrl+C` - Cancel current input
- `Ctrl+L` - Clear screen (keeping current input)
- `Enter` - Execute command
- `Backspace` - Delete character
## Mouse Support
- Click anywhere in the terminal to focus
- Scroll to view terminal history
- Click on links to open them
## Customization
### Colors
Change the color scheme by editing the theme in `terminal.js` or use the `color` command at runtime:
```
$ color amber
$ color blue
$ color white
$ color green
```
### Commands
Add new commands by extending the `commands` object in `terminal.js`:
```javascript
commands.mycommand = {
description: 'My custom command',
execute: (args) => {
return 'Output from my command';
}
};
```
## Technologies
- **xterm.js** - Terminal emulator for the web
- **xterm-addon-fit** - Responsive terminal sizing
- **xterm-addon-web-links** - Clickable URL support
- **Pure CSS** - Classic CRT effects and animations
## Browser Support
Works in all modern browsers:
- Chrome/Edge 90+
- Firefox 88+
- Safari 14+
- Opera 76+
## License
MIT

630
build.js
View File

@@ -1,176 +1,516 @@
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');
// Helper function to extract content between delimeters
function extractBlock(content, startDelimiter, endDelimiter) {
const startIndex = content.indexOf(startDelimiter);
if (startIndex === -1) return { blockContent: '', remainingContent: content };
const endIndex = content.indexOf(endDelimiter, startIndex + startDelimiter.length);
if (endIndex === -1) return { blockContent: '', remainingContent: content }; // Or throw error
const block = content.substring(startIndex + startDelimiter.length, endIndex).trim();
const remaining = content.substring(0, startIndex) + content.substring(endIndex + endDelimiter.length);
return { blockContent: block, remainingContent: remaining.trim() };
/**
* 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 };
}
// Helper function to copy files recursively
async function copyWebsiteFiles() {
const websiteDir = path.join(__dirname, 'website');
const buildDir = path.join(__dirname, 'build');
/**
* 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 websiteExists = await fs.access(websiteDir).then(() => true).catch(() => false);
if (!websiteExists) {
console.log("No website directory found, skipping copy.");
return;
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);
}
async function copyRecursive(src, dest) {
const stats = await fs.stat(src);
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);
if (stats.isDirectory()) {
await fs.mkdir(dest, { recursive: true });
const items = await fs.readdir(src);
for (const item of items) {
const fullPath = path.join(dir, item);
const stats = await fs.stat(fullPath);
for (const item of items) {
const srcPath = path.join(src, item);
const destPath = path.join(dest, item);
await copyRecursive(srcPath, destPath);
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);
}
} else {
await fs.copyFile(src, dest);
}
}
await copyRecursive(websiteDir, buildDir);
console.log("Website files copied successfully.");
await removeJsFiles(scriptsDir);
console.log('✓ Removed individual JS files');
} catch (error) {
console.error('Error copying website files:', error);
console.error('✗ JavaScript bundling failed:', error);
throw error;
}
}
async function buildPage(pageName, outputName) {
const layoutPath = path.join(__dirname, 'templates', '_layout.html');
const commonHeaderPath = path.join(__dirname, 'templates', '_header.html');
const commonFooterPath = path.join(__dirname, 'templates', '_footer.html');
const contentPath = path.join(__dirname, 'src', `${pageName}.html`);
const outputDir = path.join(__dirname, 'build');
const outputPath = path.join(outputDir, `${outputName}.html`);
try {
// Ensure the build directory exists
await fs.mkdir(outputDir, { recursive: true });
let layoutContent = await fs.readFile(layoutPath, 'utf-8');
const srcFileContent = await fs.readFile(contentPath, 'utf-8');
// Extract metadata
const metadataRegex = /<!--\s*METADATA:([\s\S]*?)-->/;
const metadataMatch = srcFileContent.match(metadataRegex);
let metadata = {};
let mainPageContent = srcFileContent;
if (metadataMatch && metadataMatch[1]) {
try {
metadata = JSON.parse(metadataMatch[1].trim());
} catch (e) {
console.error(`Error parsing metadata JSON for ${pageName}.html:`, e);
// Copy files recursively from website to build
async function copyWebsiteFiles() {
const websiteDir = path.join(__dirname, 'website');
const buildDir = path.join(__dirname, 'build');
console.log('Building site from website/ to build/...');
async function copyRecursive(src, dest) {
const stats = await fs.stat(src);
if (stats.isDirectory()) {
await fs.mkdir(dest, { recursive: true });
const items = await fs.readdir(src);
for (const item of items) {
const srcPath = path.join(src, item);
const destPath = path.join(dest, item);
await copyRecursive(srcPath, destPath);
}
mainPageContent = srcFileContent.replace(metadataRegex, '').trim();
} else {
await fs.copyFile(src, dest);
}
// Defaults for metadata
const defaults = {
title: "Litruv",
metaDescription: "Litruv's personal website.",
csp: "script-src 'self' 'unsafe-inline';",
ogTitle: "Litruv",
ogDescription: "Litruv's personal website.",
ogUrl: "https://lit.ruv.wtf",
twitterTitle: "Litruv",
twitterDescription: "Litruv's personal website.",
twitterUrl: "https://lit.ruv.wtf",
pageSpecificStyles: "",
pageSpecificScriptsDefer: "",
pageSpecificScriptsBodyEnd: "",
useCommonHeader: true,
useCommonFooter: true
};
const finalMetadata = { ...defaults, ...metadata };
// Extract page-specific styles
const stylesExtraction = extractBlock(mainPageContent, '<!-- START_STYLES -->', '<!-- END_STYLES -->');
finalMetadata.pageSpecificStyles = stylesExtraction.blockContent;
mainPageContent = stylesExtraction.remainingContent;
// Extract page-specific defer scripts
const deferScriptsExtraction = extractBlock(mainPageContent, '<!-- START_SCRIPTS_DEFER -->', '<!-- END_SCRIPTS_DEFER -->');
finalMetadata.pageSpecificScriptsDefer = deferScriptsExtraction.blockContent;
mainPageContent = deferScriptsExtraction.remainingContent;
// Extract page-specific body-end scripts
const bodyEndScriptsExtraction = extractBlock(mainPageContent, '<!-- START_SCRIPTS_BODY_END -->', '<!-- END_SCRIPTS_BODY_END -->');
finalMetadata.pageSpecificScriptsBodyEnd = bodyEndScriptsExtraction.blockContent;
mainPageContent = bodyEndScriptsExtraction.remainingContent;
// Replace metadata placeholders in layout
layoutContent = layoutContent.replace(/<!-- ::PAGE_TITLE:: -->/g, finalMetadata.title)
.replace(/<!-- ::META_DESCRIPTION:: -->/g, finalMetadata.metaDescription)
.replace(/<!-- ::PAGE_CSP:: -->/g, finalMetadata.csp)
.replace(/<!-- ::OG_TITLE:: -->/g, finalMetadata.ogTitle)
.replace(/<!-- ::OG_DESCRIPTION:: -->/g, finalMetadata.ogDescription)
.replace(/<!-- ::OG_URL:: -->/g, finalMetadata.ogUrl)
.replace(/<!-- ::TWITTER_TITLE:: -->/g, finalMetadata.twitterTitle)
.replace(/<!-- ::TWITTER_DESCRIPTION:: -->/g, finalMetadata.twitterDescription)
.replace(/<!-- ::TWITTER_URL:: -->/g, finalMetadata.twitterUrl)
.replace(/<!-- ::PAGE_SPECIFIC_STYLES:: -->/g, finalMetadata.pageSpecificStyles)
.replace(/<!-- ::PAGE_SPECIFIC_SCRIPTS_DEFER:: -->/g, finalMetadata.pageSpecificScriptsDefer)
.replace(/<!-- ::PAGE_SPECIFIC_SCRIPTS_BODY_END:: -->/g, finalMetadata.pageSpecificScriptsBodyEnd);
// Handle common header and footer
const headerContentToInject = finalMetadata.useCommonHeader ? await fs.readFile(commonHeaderPath, 'utf-8') : '';
const footerContentToInject = finalMetadata.useCommonFooter ? await fs.readFile(commonFooterPath, 'utf-8') : '';
layoutContent = layoutContent.replace('<!-- ::HEADER:: -->', headerContentToInject);
layoutContent = layoutContent.replace('<!-- ::FOOTER:: -->', footerContentToInject);
layoutContent = layoutContent.replace('<!-- ::CONTENT:: -->', mainPageContent);
await fs.writeFile(outputPath, layoutContent, 'utf-8');
console.log(`Successfully built ${outputPath}`);
} catch (error) {
console.error(`Error building page ${pageName}:`, error);
throw error; // Re-throw to fail the build process if needed
}
}
async function main() {
try {
// Copy website files first
await copyWebsiteFiles();
const srcDir = path.join(__dirname, 'src');
const files = await fs.readdir(srcDir);
const htmlFiles = files.filter(file => file.endsWith('.html'));
if (htmlFiles.length === 0) {
console.log("No HTML files found in src directory to build.");
return;
}
for (const file of htmlFiles) {
const pageName = path.parse(file).name;
const outputName = pageName;
await buildPage(pageName, outputName);
// Clean build directory
try {
await fs.rm(buildDir, { recursive: true, force: true });
} catch (err) {
// Directory might not exist, that's okay
}
console.log("All pages built successfully.");
// Copy website files
await copyRecursive(websiteDir, buildDir);
console.log('✓ Build complete! Files copied to build/');
} catch (error) {
console.error('Build process failed:', error);
console.error('Build failed:', error);
process.exit(1);
}
}
main();
/**
* 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
}
/**
* Strips markdown syntax to produce plain-text for descriptions.
*
* @param {string} md
* @returns {string}
*/
function mdToPlain(md) {
return (md || '')
.replace(/```[\s\S]*?```/g, '')
.replace(/`[^`]+`/g, '')
.replace(/!\[.*?\]\(.*?\)/g, '')
.replace(/\[([^\]]+)\]\(.*?\)/g, '$1')
.replace(/^#{1,6}\s+/gm, '')
.replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, '$1')
.replace(/^[-*_]{3,}$/gm, '')
.replace(/\n{2,}/g, ' ')
.replace(/\n/g, ' ')
.trim()
.slice(0, 400);
}
const items = posts.map(post => {
const url = `${siteUrl}/blog/${encodeURIComponent(post.slug)}/`;
const pubDate = post.date ? new Date(post.date).toUTCString() : '';
const description = xmlEscape(mdToPlain(post.content));
return [
' <item>',
` <title>${xmlEscape(post.title || post.slug)}</title>`,
` <link>${url}</link>`,
` <guid isPermaLink="true">${url}</guid>`,
pubDate ? ` <pubDate>${pubDate}</pubDate>` : '',
post.author ? ` <author>${xmlEscape(post.author)}</author>` : '',
` <description>${description}</description>`,
' </item>',
].filter(Boolean).join('\n');
});
const xml = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">',
' <channel>',
' <title>lit.ruv.wtf</title>',
` <link>${siteUrl}</link>`,
' <description>Blog posts from lit.ruv.wtf</description>',
' <language>en-us</language>',
` <atom:link href="${feedUrl}" rel="self" type="application/rss+xml" />`,
...items,
' </channel>',
'</rss>',
].join('\n');
await fs.writeFile(path.join(buildDir, 'rss.xml'), xml, 'utf-8');
console.log('✓ Generated RSS feed');
}
/**
* Generates a sitemap.xml from static routes and blog posts.
*
* @param {Array<{slug: string, date: string|null}>} posts
* @param {string} buildDir
* @returns {Promise<void>}
*/
async function generateSitemap(posts, buildDir) {
const siteUrl = 'https://lit.ruv.wtf';
const today = new Date().toISOString().slice(0, 10);
/**
* @param {string} loc
* @param {{ lastmod?: string, priority?: string }} opts
* @returns {string}
*/
function urlEntry(loc, { lastmod = today, priority = '0.80' } = {}) {
return [
' <url>',
` <loc>${loc}</loc>`,
` <lastmod>${lastmod}</lastmod>`,
` <priority>${priority}</priority>`,
' </url>',
].join('\n');
}
const staticEntries = [
urlEntry(`${siteUrl}/`, { priority: '1.00' }),
urlEntry(`${siteUrl}/blog.html`, { priority: '0.90' }),
urlEntry(`${siteUrl}/docs/sitemap.xml`, { priority: '0.80' }),
urlEntry(`${siteUrl}/materials/`, { priority: '0.80' }),
];
const postEntries = posts.map(post => {
const lastmod = post.date ? new Date(post.date).toISOString().slice(0, 10) : today;
return urlEntry(`${siteUrl}/blog/${encodeURIComponent(post.slug)}/`, { lastmod, priority: '0.50' });
});
const xml = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"',
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"',
' xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9',
' http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">',
...staticEntries,
...postEntries,
'</urlset>',
].join('\n');
await fs.writeFile(path.join(buildDir, 'sitemap.xml'), xml, 'utf-8');
console.log(`✓ Generated sitemap with ${staticEntries.length + postEntries.length} URL(s)`);
}
// Run the build
async function build() {
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();

4779
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "lit.ruv.wtf",
"version": "1.0.0",
"description": "Interactive terminal interface for lit.ruv.wtf - A retro-styled command line experience with chat, documentation, and more.",
"main": "index.html",
"scripts": {
"start": "python3 -m http.server 8000",
"start-alt": "python -m SimpleHTTPServer 8000",
"blog-editor": "node tools/blog-editor.js"
},
"keywords": [
"terminal",
"xterm",
"retro",
"cli",
"web-terminal",
"matrix",
"chat",
"interactive"
],
"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",
"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"
}
}

View File

@@ -1,16 +0,0 @@
<!--
METADATA:{
"title": "Litruv - Dev/Tech Artist",
"metaDescription": "Litruv - Dev/Tech Artist at MatesMedia. Follow for coding, game development, and creative projects.",
"csp": "script-src 'self' 'unsafe-inline'; connect-src https://public.api.bsky.app;",
"ogTitle": "Litruv - Dev/Tech Artist",
"ogDescription": "Dev/Tech Artist at MatesMedia. Follow for coding, game development, and creative projects.",
"ogUrl": "https://lit.ruv.wtf",
"twitterTitle": "Litruv - Dev/Tech Artist",
"twitterDescription": "Dev/Tech Artist at MatesMedia. Follow for coding, game development, and creative projects.",
"twitterUrl": "https://lit.ruv.wtf",
"useCommonHeader": true,
"useCommonFooter": true
}
-->
<div id="posts"></div>

View File

@@ -1,349 +0,0 @@
<!--
METADATA:{
"title": "Prison Mine - Litruv",
"metaDescription": "Infinite isometric prison mining game.",
"csp": "script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net;",
"ogTitle": "Prison Mine - Litruv",
"ogDescription": "Dig deep in this infinite isometric mining game.",
"ogUrl": "https://lit.ruv.wtf/mining.html",
"twitterTitle": "Prison Mine - Litruv",
"twitterDescription": "Dig deep in this infinite isometric mining game.",
"twitterUrl": "https://lit.ruv.wtf/mining.html",
"useCommonHeader": true,
"useCommonFooter": true
}
-->
<!-- START_STYLES -->
<style>
.mining-container {
width: min(100%, 80vh);
aspect-ratio: 1;
margin: 0 auto;
position: relative;
overflow: hidden;
background: #000;
border: 2px solid var(--post-border);
box-shadow: 4px 6px 0 var(--shadow-light);
}
#game-canvas {
width: 100%;
height: 100%;
display: block;
position: absolute;
top: 0;
left: 0;
z-index: 0;
}
.mining-ui {
position: absolute;
top: 10px;
left: 10px;
color: white;
font-family: 'Minecraft', monospace; /* Fallback */
pointer-events: none;
text-shadow: 2px 2px 0 #000;
z-index: 100;
}
.mining-stat {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.inventory-container {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 5px;
background: rgba(0, 0, 0, 0.5);
padding: 5px;
border-radius: 5px;
pointer-events: auto;
overflow-x: auto;
max-width: 95%;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
z-index: 100;
}
/* Scrollbar styling for inventory */
.inventory-container::-webkit-scrollbar {
height: 6px;
}
.inventory-container::-webkit-scrollbar-track {
background: rgba(0,0,0,0.3);
border-radius: 3px;
}
.inventory-container::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.3);
border-radius: 3px;
}
.inventory-slot {
flex: 0 0 auto; /* Don't shrink */
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.1);
border: 2px solid #555;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.inventory-slot img {
width: 32px;
height: 32px;
image-rendering: pixelated;
}
.inventory-count {
position: absolute;
bottom: 0;
right: 2px;
font-size: 12px;
color: white;
text-shadow: 1px 1px 0 #000;
}
.crafting-container {
position: absolute;
top: 50%;
right: 10px;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 5px;
background: rgba(0, 0, 0, 0.5);
padding: 5px;
border-radius: 5px;
pointer-events: auto; /* Allow clicking */
overflow-y: auto;
max-height: 60vh;
-webkit-overflow-scrolling: touch;
z-index: 100;
}
/* Scrollbar styling for crafting */
.crafting-container::-webkit-scrollbar {
width: 6px;
}
.crafting-container::-webkit-scrollbar-track {
background: rgba(0,0,0,0.3);
border-radius: 3px;
}
.crafting-container::-webkit-scrollbar-thumb {
background: rgba(255,255,255,0.3);
border-radius: 3px;
}
.crafting-slot {
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.1);
border: 2px solid #555;
position: relative;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s;
}
.crafting-slot:hover {
background: rgba(255, 255, 255, 0.2);
}
.crafting-slot.can-craft {
border-color: #79C05A;
background: rgba(121, 192, 90, 0.2);
}
.crafting-slot img {
width: 32px;
height: 32px;
image-rendering: pixelated;
}
.crafting-tooltip {
position: absolute;
right: 100%;
top: 50%;
transform: translateY(-50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 5px;
border-radius: 3px;
font-size: 12px;
white-space: nowrap;
display: none;
margin-right: 10px;
pointer-events: none;
z-index: 20;
}
.crafting-slot:hover .crafting-tooltip {
display: block;
}
.shop-btn {
position: absolute;
top: 10px;
right: 10px;
background: #4CAF50;
color: white;
border: 2px solid #388E3C;
padding: 5px 10px;
font-family: 'Minecraft', monospace;
cursor: pointer;
z-index: 100;
}
.shop-container {
position: absolute;
top: 10%;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.9);
border: 2px solid #555;
padding: 20px;
color: white;
font-family: 'Minecraft', monospace;
display: none;
z-index: 101;
min-width: 300px;
max-height: 80vh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.shop-section {
margin-bottom: 20px;
}
.shop-section h3 {
border-bottom: 1px solid #555;
padding-bottom: 5px;
margin-top: 0;
}
.shop-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
padding: 5px;
background: rgba(255, 255, 255, 0.05);
}
.shop-item button {
background: #333;
color: white;
border: 1px solid #555;
padding: 2px 5px;
cursor: pointer;
font-family: inherit;
}
.shop-item button:hover {
background: #555;
}
.close-shop {
position: absolute;
top: 5px;
right: 5px;
background: none;
border: none;
color: white;
font-size: 20px;
cursor: pointer;
}
/* Override for buttons inside shop */
.shop-container .shop-btn {
position: static;
display: block;
width: 100%;
margin-bottom: 5px;
background: #333;
text-align: left;
}
.shop-container .shop-btn:hover {
background: #555;
}
.shop-container .close-btn {
text-align: center;
background: #d32f2f;
border-color: #b71c1c;
}
.shop-container .close-btn:hover {
background: #f44336;
}
.fullscreen-btn {
position: absolute;
top: 10px;
right: 80px;
background: #2196F3;
color: white;
border: 2px solid #1976D2;
padding: 5px 10px;
font-family: 'Minecraft', monospace;
cursor: pointer;
z-index: 100;
}
.fullscreen-btn:hover {
background: #42A5F5;
}
.mining-container:fullscreen {
width: 100vw;
height: 100vh;
max-width: none;
aspect-ratio: auto;
border: none;
background: #87CEEB; /* Match sky color */
}
.mining-container:fullscreen #game-canvas {
width: 100vw;
height: 100vh;
}
/* Fallback for iOS/Mobile */
.mining-container.pseudo-fullscreen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
max-width: none;
aspect-ratio: auto;
border: none;
background: #87CEEB;
z-index: 9999;
margin: 0;
}
.mining-container.pseudo-fullscreen #game-canvas {
width: 100vw;
height: 100vh;
}
</style>
<!-- END_STYLES -->
<!-- START_SCRIPTS_DEFER -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/CopyShader.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/SSAOShader.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/EffectComposer.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/RenderPass.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/ShaderPass.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/SSAOPass.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/GammaCorrectionShader.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/math/SimplexNoise.js" defer></script>
<script src="mining.js" defer></script>
<!-- END_SCRIPTS_DEFER -->
<div class="mining-container">
<div class="mining-ui">
<div class="mining-stat">Depth: <span id="depth-counter">0</span></div>
<div class="mining-stat">Score: <span id="score-counter">0</span></div>
</div>
<button id="fullscreen-btn" class="fullscreen-btn" onclick="toggleFullscreen()">[ ]</button>
<button id="shop-btn" class="shop-btn" onclick="toggleShop()">Shop</button>
<div id="inventory-display" class="inventory-container">
<!-- Slots will be added here dynamically -->
</div>
<div id="crafting-display" class="crafting-container">
<!-- Crafting slots will be added here dynamically -->
</div>
<div id="shop-modal" class="shop-container" style="display:none; flex-direction:column; max-height: 80vh; overflow-y: auto;">
<div id="shop-content"></div>
</div>
<canvas id="game-canvas"></canvas>
</div>

View File

@@ -1,498 +0,0 @@
<!--
METADATA:{
"title": "Number Match - Litruv",
"metaDescription": "Play Number Match right on Litruv's site. Pair numbers that match or add to ten and clear the board.",
"csp": "script-src 'self' 'unsafe-inline';",
"ogTitle": "Number Match - Litruv",
"ogDescription": "Clear the board by pairing numbers that match or add to ten.",
"ogUrl": "https://lit.ruv.wtf/numbermatch.html",
"twitterTitle": "Number Match - Litruv",
"twitterDescription": "Clear the board by pairing numbers that match or add to ten.",
"twitterUrl": "https://lit.ruv.wtf/numbermatch.html",
"useCommonHeader": true,
"useCommonFooter": true
}
-->
<!-- START_STYLES -->
<style>
.numbermatch-page {
margin-top: 2.5rem;
padding: 0 1rem 3.5rem;
color: #1f2933;
}
.numbermatch-page .sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.numbermatch-page .app {
display: grid;
gap: 2.5rem;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
max-width: 1100px;
margin: 0 auto;
}
.numbermatch-page .app__game,
.numbermatch-page .app__info {
background: var(--post-bg);
border: 2px solid var(--post-border);
box-shadow: 4px 6px 0 var(--shadow-light);
padding: 2rem 2.25rem;
position: relative;
transform-origin: center;
transition: transform 0.25s ease, box-shadow 0.25s ease;
}
.numbermatch-page .app__game {
transform: none;
}
.numbermatch-page .app__info {
transform: rotate(0.55deg);
}
.numbermatch-page .app__game:hover {
transform: scale(1.01);
box-shadow: 6px 9px 0 var(--shadow-light);
}
.numbermatch-page .app__info:hover {
transform: rotate(0deg) scale(1.01);
box-shadow: 6px 9px 0 var(--shadow-light);
}
.numbermatch-page .app__title {
margin: 0;
font-size: 2rem;
text-shadow: 1px 1px 0 #fff;
color: #111827;
}
.numbermatch-page .app__subtitle {
margin: 0.5rem 0 0;
color: #364152;
font-size: 1rem;
}
.numbermatch-page .controls {
display: flex;
flex-wrap: wrap;
gap: 0.85rem;
margin: 2rem 0 1.5rem;
}
.numbermatch-page .control-button {
border: 2px solid var(--post-border);
background: var(--accent);
color: #fff;
cursor: pointer;
font-size: 1rem;
font-weight: 600;
padding: 0.65rem 1.75rem;
box-shadow: 3px 4px 0 var(--shadow-light);
transition: transform 0.18s ease, box-shadow 0.18s ease;
font-family: inherit;
}
.numbermatch-page .control-button:hover,
.numbermatch-page .control-button:focus {
transform: translateY(-2px);
box-shadow: 5px 6px 0 var(--shadow-light);
}
.numbermatch-page .control-button:active {
transform: translateY(0);
box-shadow: 2px 2px 0 var(--shadow-light);
}
.numbermatch-page .control-button--secondary {
background: var(--main-bg);
color: var(--main-fg);
}
.numbermatch-page .status {
display: flex;
flex-direction: column;
gap: 0.85rem;
background: rgba(36, 36, 36, 0.04);
border: 1px dashed rgba(0, 0, 0, 0.12);
padding: 1rem 1.25rem;
border-radius: 12px;
}
.numbermatch-page .status__message {
min-height: 1.5rem;
margin: 0;
font-weight: 600;
color: #1f2933;
}
.numbermatch-page .status__message.status__message--error {
color: #be123c;
}
.numbermatch-page .status__message.status__message--success {
color: #15803d;
}
.numbermatch-page .status__metrics {
display: flex;
flex-wrap: wrap;
gap: 1rem;
font-size: 0.95rem;
color: #364152;
}
.numbermatch-page .status__metric strong {
color: #111827;
}
.numbermatch-page .board {
margin-top: 1.75rem;
display: grid;
gap: 0.6rem;
width: 100%;
}
.numbermatch-page .board-actions {
margin-top: 1.75rem;
display: flex;
justify-content: center;
align-items: center;
gap: 0.85rem;
}
.numbermatch-page .board-action {
width: 3.5rem;
height: 3.5rem;
border-radius: 50%;
border: 2px solid var(--post-border);
display: inline-flex;
justify-content: center;
align-items: center;
font-size: 2rem;
font-weight: 700;
cursor: pointer;
color: var(--main-bg);
background: var(--accent);
box-shadow: 3px 4px 0 var(--shadow-light);
transition: transform 0.18s ease, box-shadow 0.18s ease;
position: relative;
}
.numbermatch-page .board-action:hover,
.numbermatch-page .board-action:focus {
transform: translateY(-2px);
box-shadow: 5px 6px 0 var(--shadow-light);
}
.numbermatch-page .board-action:active {
transform: translateY(0);
box-shadow: 2px 2px 0 var(--shadow-light);
}
.numbermatch-page .board-action--secondary {
background: var(--post-bg);
color: #111827;
}
.numbermatch-page .board-action__badge {
position: absolute;
top: -0.45rem;
right: -0.45rem;
min-width: 1.5rem;
padding: 0.2rem 0.45rem;
border-radius: 999px;
background: var(--like);
color: #fff;
font-size: 0.85rem;
font-weight: 700;
box-shadow: 0 6px 12px rgba(219, 39, 119, 0.35);
}
.numbermatch-page .board-action__badge.board-action__badge--empty {
background: rgba(0, 0, 0, 0.12);
color: #111827;
box-shadow: none;
}
.numbermatch-page .board-action--attention {
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.18), 0 12px 24px rgba(37, 99, 235, 0.3);
animation: numbermatch-attention 0.95s ease-in-out infinite alternate;
}
@keyframes numbermatch-attention {
from {
transform: translateY(-2px) scale(1);
}
to {
transform: translateY(-3px) scale(1.05);
}
}
.numbermatch-page .tile {
width: 100%;
aspect-ratio: 1 / 1;
border-radius: 14px;
border: 1.5px solid var(--post-border);
font-size: clamp(1.1rem, 4vw, 1.55rem);
font-weight: 700;
color: #111827;
background: linear-gradient(165deg, #fffef4 0%, #f7f0b7 100%);
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
box-shadow: 2px 3px 0 rgba(0, 0, 0, 0.15);
transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
position: relative;
}
.numbermatch-page .tile:hover,
.numbermatch-page .tile:focus {
transform: translateY(-2px);
box-shadow: 4px 6px 0 rgba(0, 0, 0, 0.18);
}
.numbermatch-page .tile--selected {
background: linear-gradient(160deg, #fde68a 0%, #fb923c 100%);
box-shadow: 4px 6px 0 rgba(251, 191, 36, 0.4);
}
.numbermatch-page .tile--hinted:not(.tile--selected) {
background: linear-gradient(160deg, #bfdbfe 0%, #93c5fd 100%);
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.35), 4px 6px 0 rgba(37, 99, 235, 0.25);
}
.numbermatch-page .tile--empty {
background: rgba(0, 0, 0, 0.05);
border: 1.5px dashed rgba(0, 0, 0, 0.25);
cursor: default;
box-shadow: none;
}
.numbermatch-page .tile--empty:hover,
.numbermatch-page .tile--empty:focus {
transform: none;
}
.numbermatch-page .app__info h2,
.numbermatch-page .app__info h3 {
margin-top: 0;
color: #111827;
text-shadow: 1px 1px 0 #fff;
}
.numbermatch-page .app__info p,
.numbermatch-page .app__info li {
color: #364152;
line-height: 1.6;
}
.numbermatch-page .app__info ul {
padding-left: 1.25rem;
margin: 0.75rem 0 1.5rem;
}
.numbermatch-dialog-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
.numbermatch-dialog-backdrop.is-visible {
opacity: 1;
pointer-events: all;
}
.numbermatch-dialog {
background: var(--post-bg);
border: 2px solid var(--post-border);
box-shadow: 6px 8px 0 var(--shadow-light);
padding: 1.75rem 2rem;
max-width: 420px;
width: calc(100% - 2rem);
transform: rotate(-1deg);
position: relative;
}
.numbermatch-dialog h2 {
margin-top: 0;
margin-bottom: 0.75rem;
font-size: 1.35rem;
color: #111827;
}
.numbermatch-dialog p {
margin: 0 0 1.5rem;
color: #374151;
line-height: 1.5;
}
.numbermatch-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.numbermatch-dialog-button {
border: 2px solid var(--post-border);
padding: 0.55rem 1.35rem;
font-family: inherit;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
box-shadow: 3px 4px 0 var(--shadow-light);
transition: transform 0.18s ease, box-shadow 0.18s ease;
background: var(--post-bg);
color: #111827;
}
.numbermatch-dialog-button:hover,
.numbermatch-dialog-button:focus-visible {
transform: translateY(-2px);
box-shadow: 5px 6px 0 var(--shadow-light);
}
.numbermatch-dialog-button--confirm {
background: var(--accent);
color: #fff;
}
.numbermatch-dialog-button--danger {
background: var(--like);
color: #fff;
}
@media (max-width: 1023px) {
.numbermatch-page .app {
grid-template-columns: minmax(0, 1fr);
}
.numbermatch-page .app__game,
.numbermatch-page .app__info {
transform: rotate(0deg);
}
}
@media (max-width: 640px) {
.numbermatch-page {
padding: 0 0 3rem;
}
.numbermatch-page .app__game,
.numbermatch-page .app__info {
padding: 1.5rem;
}
.numbermatch-page .board {
gap: 0.4rem;
}
.numbermatch-page .board-action {
width: 3.1rem;
height: 3.1rem;
font-size: 1.8rem;
}
.numbermatch-page .board-action__badge {
top: -0.25rem;
right: -0.25rem;
min-width: 1.35rem;
font-size: 0.75rem;
}
}
</style>
<!-- END_STYLES -->
<!-- START_SCRIPTS_DEFER -->
<script src="numbermatch.js" defer></script>
<!-- END_SCRIPTS_DEFER -->
<div class="numbermatch-page">
<div class="app">
<section class="app__game" aria-label="Number Match board and controls">
<header class="app__header">
<h1 class="app__title">Number Match</h1>
<p class="app__subtitle">Clear the board by pairing numbers that match or add to ten.</p>
</header>
<div class="controls" role="group" aria-label="Game controls">
<button id="newGameButton" type="button" class="control-button control-button--secondary">New Game</button>
</div>
<section class="status" aria-live="polite">
<p id="message" class="status__message" role="status"></p>
<div class="status__metrics">
<span class="status__metric">
Matches cleared: <strong id="matchesCount">0</strong>
</span>
<span class="status__metric">
Tiles remaining: <strong id="tilesRemaining">0</strong>
</span>
<span class="status__metric">
Add Numbers used: <strong id="addNumbersUsed">0/3</strong>
</span>
<span class="status__metric">
Hints used: <strong id="hintsUsed">0</strong>
</span>
</div>
</section>
<section class="board" id="board" aria-label="Number grid"></section>
<div class="board-actions">
<button id="addNumbersButton" type="button" class="board-action board-action--primary" aria-label="Add numbers">
<span aria-hidden="true" class="board-action__icon">+</span>
<span id="addNumbersCounter" class="board-action__badge" aria-hidden="true">3</span>
<span class="sr-only">Add numbers</span>
</button>
<button id="hintButton" type="button" class="board-action board-action--secondary" aria-label="Hint">
<span aria-hidden="true">?</span>
<span class="sr-only">Hint</span>
</button>
</div>
</section>
<aside class="app__info" aria-labelledby="info-heading">
<h2 id="info-heading">How To Play</h2>
<p>Select two tiles that are either identical (like 8 and 8) or sum to ten (for example, 3 and 7). Clear every number to win.</p>
<h3>Match Rules</h3>
<ul>
<li>Paths can run horizontally, vertically, diagonally, or wrap in reading order.</li>
<li>Every cell between selections must be empty along the chosen path.</li>
<li>Wrap-around allows the end of a row to connect to the start of the next when the path is clear.</li>
</ul>
<h3>Strategy &amp; Tips</h3>
<ul>
<li>Scan rows and columns for classic pairs like 9-1, 8-2, 7-3, 6-4, and 5-5.</li>
<li>Use diagonal and wrap-around matches to reach blocked numbers.</li>
<li><em>Add Numbers</em> unlocks fresh tiles in three batches—save it for when the board runs dry.</li>
</ul>
<h3>Frequently Asked Questions</h3>
<p><strong>Which numbers appear?</strong> Numbers 1-9 are distributed to encourage plenty of identical and ten-sum pairs.</p>
<p><strong>What counts as wrap-around?</strong> Reading order connections across a row break, provided every intervening cell is empty.</p>
</aside>
</div>
</div>
<div class="numbermatch-dialog-backdrop" id="newGameDialogBackdrop" hidden>
<div class="numbermatch-dialog" id="newGameDialog" role="dialog" aria-modal="true" aria-labelledby="newGameDialogTitle" aria-describedby="newGameDialogDescription" tabindex="-1">
<h2 id="newGameDialogTitle">Start a fresh puzzle?</h2>
<p id="newGameDialogDescription">Your current board and progress will be replaced with a new layout. You can always reload the page to resume later.</p>
<div class="numbermatch-dialog-actions">
<button type="button" class="numbermatch-dialog-button" id="cancelNewGameButton">Keep playing</button>
<button type="button" class="numbermatch-dialog-button numbermatch-dialog-button--danger" id="confirmNewGameButton">New game</button>
</div>
</div>
</div>

View File

@@ -1,97 +0,0 @@
<!--
METADATA:{
"title": "Privacy Policy - Litruv",
"metaDescription": "Privacy Policy for Litruv's website and applications.",
"csp": "script-src 'self' 'unsafe-inline';",
"ogTitle": "Privacy Policy - Litruv",
"ogDescription": "Privacy Policy for Litruv's website and applications.",
"ogUrl": "https://lit.ruv.wtf/privacy.html",
"twitterTitle": "Privacy Policy - Litruv",
"twitterDescription": "Privacy Policy for Litruv's website and applications.",
"twitterUrl": "https://lit.ruv.wtf/privacy.html",
"useCommonHeader": false,
"useCommonFooter": true
}
-->
<!-- START_STYLES -->
<style>
.privacy-content {
background: white;
padding: 1.5rem 2rem;
margin-top: 2rem;
border: 2px solid #000;
box-shadow: 3px 3px 0 rgba(0, 0, 0, 0.1), 6px 6px 0 rgba(0, 0, 0, 0.05);
color: #333;
text-align: left;
}
.privacy-content h1 {
text-align: center;
font-size: 1.8rem;
color: #242424;
text-shadow: 1px 1px 0 #ffffff52, 2px 2px 0 #00000080;
margin-bottom: 1.5rem;
}
.privacy-content h2 {
font-size: 1.3rem;
color: #242424;
margin-top: 1.5rem;
margin-bottom: 0.5rem;
}
.privacy-content p {
line-height: 1.6;
margin-bottom: 1rem;
}
.privacy-content a {
color: #2563eb;
text-decoration: underline;
}
.privacy-content a:hover {
color: #1d4ed8;
}
</style>
<!-- END_STYLES -->
<!-- START_SCRIPTS_BODY_END -->
<script>
function getRandomRotation() {
return (Math.random() * 3) - 1.5;
}
document.addEventListener('DOMContentLoaded', function() {
const privacyBox = document.querySelector('.privacy-content');
if (privacyBox) {
privacyBox.style.transform = `rotate(${getRandomRotation()}deg)`;
}
// Ensure avatar also gets random rotation if desired for this page
const avatarImg = document.querySelector('.avatar-img');
if (avatarImg) {
// You might want a different rotation range or logic here
// For consistency with how posts might be styled if they were on this page:
// avatarImg.style.transform = `rotate(${(Math.random() * 6) - 3}deg)`;
}
});
</script>
<!-- END_SCRIPTS_BODY_END -->
<!-- Main page content for privacy.html -->
<img src="avatar.webp" alt="Avatar" class="avatar-img" width="250" height="250">
<header>
<h1><a href="/">Litruv</a></h1>
<div class="subtext">Privacy Policy</div>
</header>
<div class="privacy-content">
<h1>Privacy Policy</h1>
<p>I respect your privacy.</p>
<h2>Apps</h2>
<p>My apps do not collect, store, or share any personal information. No analytics, no tracking, no funny business.</p>
<h2>Website</h2>
<p>This site may collect anonymous usage data (such as page views or general browser information) to help improve functionality and performance. This data is non-personal and cannot be used to identify you. I do not sell or share this data with third parties.</p>
<p>Additionally, this site uses Cloudflare for performance and security. Cloudflare may collect its own analytics and statistical data, subject to their <a href="https://www.cloudflare.com/privacypolicy/" target="_blank" rel="noopener noreferrer">privacy policy</a>.</p>
<p>By using this site, you agree to this policy.</p>
</div>

View File

@@ -1,7 +0,0 @@
<footer>
<a href="privacy.html">Privacy Policy</a>
<p>&copy; 2013-<span id="current-year"></span> Max Litruv Boonzaayer</p>
<script>
document.getElementById('current-year').textContent = new Date().getFullYear();
</script>
</footer>

View File

@@ -1,32 +0,0 @@
<img src="avatar.webp" alt="Avatar" class="avatar-img" width="250" height="250">
<header>
<h1>Litruv</h1>
<div class="subtext">Dev/Tech Artist @<a href="https://mates.dev/">MatesMedia</a>
</div>
<div class="social-links">
<a href="https://github.com/Litruv" target="_blank" title="GitHub - Litruv">
<i class="fab fa-github"></i>
</a>
<a href="https://www.youtube.com/c/Litruv" target="_blank" title="YouTube - Litruv">
<i class="fab fa-youtube"></i>
</a>
<a href="https://steamcommunity.com/id/Litruv" target="_blank" title="Steam - Litruv">
<i class="fab fa-steam"></i>
</a>
<a href="https://discordapp.com/users/220772082055774210" target="_blank" title="Discord - @Litruv">
<i class="fab fa-discord"></i>
</a>
<a href="https://bsky.app/profile/lit.mates.dev" target="_blank" title="Bluesky - lit.mates.dev">
<i class="fa-brands fa-bluesky"></i>
</a>
</div>
<div class="project-links">
<a href="https://mates.dev" target="_blank">MatesMedia</a>
<a href="https://lit.ruv.wtf/materials/" target="_blank">UE Material Snippets</a>
<a href="https://lit.ruv.wtf/docs/" target="_blank">Documentation</a>
<a href="https://lit.ruv.wtf/numbermatch/">Number Match</a>
<a href="https://lit.ruv.wtf/mining/">Mining Game</a>
</div>
</header>

View File

@@ -1,52 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<meta name="description" content="<!-- ::META_DESCRIPTION:: -->">
<meta name="author" content="Litruv">
<meta http-equiv="Content-Security-Policy" content="<!-- ::PAGE_CSP:: -->">
<meta property="og:title" content="<!-- ::OG_TITLE:: -->">
<meta property="og:description" content="<!-- ::OG_DESCRIPTION:: -->">
<meta property="og:image" content="https://lit.ruv.wtf/banner.webp"> <!-- Common image -->
<meta property="og:url" content="<!-- ::OG_URL:: -->">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Lit.ruv.wtf" /> <!-- Common site name -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="<!-- ::TWITTER_TITLE:: -->">
<meta name="twitter:description" content="<!-- ::TWITTER_DESCRIPTION:: -->">
<meta name="twitter:image" content="https://lit.ruv.wtf/banner.webp"> <!-- Common image -->
<meta name="twitter:url" content="<!-- ::TWITTER_URL:: -->">
<link rel="icon" type="image/png" sizes="16x16" href="/icons/16.png">
<link rel="icon" type="image/png" sizes="32x32" href="/icons/32.png">
<link rel="icon" type="image/png" sizes="48x48" href="/icons/48.png">
<link rel="apple-touch-icon" sizes="180x180" href="/icons/180.png">
<link rel="stylesheet" href="style.css" crossorigin="">
<!-- ::PAGE_SPECIFIC_STYLES:: -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.7/hls.min.js" defer></script>
<script src="main.js" defer></script> <!-- Common main script -->
<script src="dynamic-rotation.js" defer></script> <!-- Common main script -->
<script src="tape.js" defer></script>
<!-- ::PAGE_SPECIFIC_SCRIPTS_DEFER:: -->
<title><!-- ::PAGE_TITLE:: --></title>
</head>
<body>
<div class="bg-pattern"></div>
<div class="container">
<!-- ::HEADER:: -->
<!-- ::CONTENT:: -->
<!-- ::FOOTER:: -->
</div>
<!-- ::PAGE_SPECIFIC_SCRIPTS_BODY_END:: -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.1/js/all.min.js" defer=""></script>
</body>
</html>

View 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">&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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
}
module.exports = {
StaticBlogGenerator
};

View File

@@ -0,0 +1,4 @@
# Exclude source/build artifacts — keep only what's needed in the VSIX
build-dist.js
*.vsix
.gitignore

Binary file not shown.

Binary file not shown.

View 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); });

View 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

File diff suppressed because it is too large Load Diff

View 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 };

View File

@@ -0,0 +1,13 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Blog Editor Extension",
"type": "extensionHost",
"request": "launch",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}/.vscode"
]
}
]
}

View 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
View 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"
}
}
}
}

View 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"
}
}

View 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
View 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 };

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 379 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 367 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 470 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 438 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 813 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 476 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 958 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 375 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 513 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 506 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 273 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 321 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 306 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 534 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 414 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 584 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 368 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 674 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 568 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 632 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 405 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 570 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 349 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 675 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 407 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 550 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 335 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 160 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 469 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 740 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 823 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 495 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 749 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 660 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 629 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 666 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 707 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 639 B

Some files were not shown because too many files have changed in this diff Show More