Compare commits
66 Commits
361ea9ba90
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a4926c1368 | |||
| b09a329d62 | |||
| 655d16ea6b | |||
| cf08709151 | |||
| 37d6388244 | |||
| 13fa54401f | |||
| ffe6ab9f90 | |||
| 7091b6844e | |||
| 0834d30e9b | |||
| 52c196515e | |||
| 19a3b06682 | |||
| 868381c9c8 | |||
| 16f72f58ce | |||
| e165203a8b | |||
| 770cc37171 | |||
| 45774f4d4d | |||
| bfa7cfcb36 | |||
| d990adf193 | |||
| 6e1728d35e | |||
| 7007660a80 | |||
| ba36cdd05b | |||
| fabbb0539a | |||
| daef942927 | |||
| a16445dd78 | |||
| 35b049b833 | |||
| b8e357e76b | |||
| 924ebfa164 | |||
| ed797df9b4 | |||
| d65cadc2e5 | |||
| 01bac4c0bd | |||
| 4e4665c4ca | |||
| 628adc59ee | |||
| f56a1e9181 | |||
| 2625ecc55b | |||
| 715bf07eae | |||
| a9b65b1dba | |||
| 0f59de54bb | |||
| 40fd8e8e42 | |||
| 34c2aa4316 | |||
| 7e7e6f2c22 | |||
| 3c45406f6f | |||
| f40856d747 | |||
| 17e52681d7 | |||
| d762f8e67e | |||
| df10268a83 | |||
| 6d7ee1db71 | |||
| 1f658c179f | |||
| 5a782e46f8 | |||
| 6c381e8bbc | |||
| 3ae550bb76 | |||
| 6a66d9c097 | |||
| 9b9ab8593f | |||
| 6529eb2e6b | |||
| 7d9a182b4d | |||
| 864b1e3ecc | |||
| 628a0cb948 | |||
| 3d987dced1 | |||
| 288b48ddab | |||
| bf283e8425 | |||
| da4f07b8fe | |||
| 4f5664a633 | |||
| fb86ebbd0d | |||
| 134e917cac | |||
| b79cbf0545 | |||
| 7f5ae16dea | |||
| 4be791f90e |
131
.gitignore
vendored
@@ -1,119 +1,34 @@
|
|||||||
# Logs
|
# Node modules
|
||||||
logs
|
node_modules/
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
# OS files
|
||||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
# Runtime data
|
# IDE
|
||||||
pids
|
.vscode/
|
||||||
*.pid
|
.idea/
|
||||||
*.seed
|
*.sublime-project
|
||||||
*.pid.lock
|
*.sublime-workspace
|
||||||
|
|
||||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
# Build outputs
|
||||||
lib-cov
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
# Environment variables
|
||||||
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
|
|
||||||
.env
|
.env
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
.env.local
|
.env.local
|
||||||
|
|
||||||
# parcel-bundler cache files
|
# Logs
|
||||||
.cache
|
*.log
|
||||||
.parcel-cache
|
logs/
|
||||||
|
|
||||||
# Next.js build output
|
# Temporary files
|
||||||
.next
|
*.tmp
|
||||||
out
|
temp/
|
||||||
|
|
||||||
# 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/
|
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"liveServer.settings.port": 5502
|
|
||||||
}
|
|
||||||
123
README.md
@@ -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
|
||||||
|
|||||||
618
build.js
@@ -1,176 +1,516 @@
|
|||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const path = require('path');
|
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) {
|
* Parse YAML front matter from markdown content
|
||||||
const startIndex = content.indexOf(startDelimiter);
|
* @param {string} content - The markdown file content
|
||||||
if (startIndex === -1) return { blockContent: '', remainingContent: content };
|
* @returns {{metadata: Object, content: string}}
|
||||||
const endIndex = content.indexOf(endDelimiter, startIndex + startDelimiter.length);
|
*/
|
||||||
if (endIndex === -1) return { blockContent: '', remainingContent: content }; // Or throw error
|
function parseMarkdownWithFrontmatter(content) {
|
||||||
|
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/;
|
||||||
|
const match = content.match(frontmatterRegex);
|
||||||
|
|
||||||
const block = content.substring(startIndex + startDelimiter.length, endIndex).trim();
|
if (!match) {
|
||||||
const remaining = content.substring(0, startIndex) + content.substring(endIndex + endDelimiter.length);
|
return { metadata: {}, content: content };
|
||||||
return { blockContent: block, remainingContent: remaining.trim() };
|
}
|
||||||
|
|
||||||
|
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() {
|
* Process blog markdown files and generate blogs.json, also update graph.json
|
||||||
const websiteDir = path.join(__dirname, 'website');
|
* @param {string} buildDir - Path to the build output directory
|
||||||
const buildDir = path.join(__dirname, 'build');
|
*/
|
||||||
|
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 {
|
try {
|
||||||
const websiteExists = await fs.access(websiteDir).then(() => true).catch(() => false);
|
const files = await fs.readdir(blogDir);
|
||||||
if (!websiteExists) {
|
const mdFiles = files.filter(f => f.endsWith('.md'));
|
||||||
console.log("No website directory found, skipping copy.");
|
|
||||||
return;
|
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
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyRecursive(src, dest) {
|
// Sort by date descending (newest first)
|
||||||
const stats = await fs.stat(src);
|
posts.sort((a, b) => {
|
||||||
|
if (!a.date) return 1;
|
||||||
|
if (!b.date) return -1;
|
||||||
|
return new Date(b.date) - new Date(a.date);
|
||||||
|
});
|
||||||
|
|
||||||
if (stats.isDirectory()) {
|
await fs.writeFile(outputPath, JSON.stringify(posts, null, 2));
|
||||||
await fs.mkdir(dest, { recursive: true });
|
console.log(`✓ Processed ${posts.length} blog post(s)`);
|
||||||
const items = await fs.readdir(src);
|
|
||||||
|
|
||||||
for (const item of items) {
|
// Update graph.json with blog nodes
|
||||||
const srcPath = path.join(src, item);
|
if (posts.length > 0) {
|
||||||
const destPath = path.join(dest, item);
|
await updateGraphWithBlogNodes(posts, graphPath);
|
||||||
await copyRecursive(srcPath, destPath);
|
}
|
||||||
|
|
||||||
|
await staticBlogGenerator.generate(posts);
|
||||||
|
console.log('✓ Generated static blog pages');
|
||||||
|
await generateRssFeed(posts, buildDir);
|
||||||
|
return posts;
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === 'ENOENT') {
|
||||||
|
console.log('⚠ No blog directory found, skipping blog processing');
|
||||||
|
await staticBlogGenerator.generate([]);
|
||||||
|
console.log('✓ Generated empty static blog index');
|
||||||
|
return [];
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update graph.json with blog post nodes
|
||||||
|
* @param {Array} posts - Array of blog post objects
|
||||||
|
* @param {string} graphPath - Path to graph.json
|
||||||
|
*/
|
||||||
|
async function updateGraphWithBlogNodes(posts, graphPath) {
|
||||||
|
const graphData = JSON.parse(await fs.readFile(graphPath, 'utf-8'));
|
||||||
|
|
||||||
|
// Remove existing blog nodes and their connections
|
||||||
|
graphData.nodes = graphData.nodes.filter(node => node.type !== 'blog_post');
|
||||||
|
graphData.connections = graphData.connections.filter(conn => {
|
||||||
|
const fromNode = graphData.nodes.find(n => n.id === conn.from.nodeId);
|
||||||
|
const toNode = graphData.nodes.find(n => n.id === conn.to.nodeId);
|
||||||
|
return fromNode && toNode;
|
||||||
|
});
|
||||||
|
|
||||||
|
const startX = 1500;
|
||||||
|
const startY = 900;
|
||||||
|
const horizontalSpacing = 700;
|
||||||
|
const nodeWidth = 600;
|
||||||
|
|
||||||
|
const blogNodes = [];
|
||||||
|
const blogConnections = [];
|
||||||
|
|
||||||
|
posts.forEach((post, index) => {
|
||||||
|
const nodeId = `blog_${post.slug}`;
|
||||||
|
const x = startX + (index * horizontalSpacing);
|
||||||
|
|
||||||
|
const node = {
|
||||||
|
id: nodeId,
|
||||||
|
type: 'blog_post',
|
||||||
|
title: post.title,
|
||||||
|
blogSlug: post.slug,
|
||||||
|
position: {
|
||||||
|
x: x,
|
||||||
|
y: startY
|
||||||
|
},
|
||||||
|
width: nodeWidth,
|
||||||
|
inputs: index > 0 ? [{
|
||||||
|
id: 'exec_in',
|
||||||
|
name: 'Previous',
|
||||||
|
direction: 'input',
|
||||||
|
kind: 'exec'
|
||||||
|
}] : [],
|
||||||
|
outputs: index < posts.length - 1 ? [{
|
||||||
|
id: 'exec_out',
|
||||||
|
name: 'Next',
|
||||||
|
direction: 'output',
|
||||||
|
kind: 'exec'
|
||||||
|
}] : []
|
||||||
|
};
|
||||||
|
|
||||||
|
blogNodes.push(node);
|
||||||
|
|
||||||
|
// Create connection to next post
|
||||||
|
if (index < posts.length - 1) {
|
||||||
|
const nextNodeId = `blog_${posts[index + 1].slug}`;
|
||||||
|
blogConnections.push({
|
||||||
|
id: `blog_connection_${index}`,
|
||||||
|
from: {
|
||||||
|
nodeId: nodeId,
|
||||||
|
pinId: 'exec_out'
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
nodeId: nextNodeId,
|
||||||
|
pinId: 'exec_in'
|
||||||
|
},
|
||||||
|
kind: 'exec'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add blog nodes and connections to graph
|
||||||
|
graphData.nodes.push(...blogNodes);
|
||||||
|
graphData.connections.push(...blogConnections);
|
||||||
|
|
||||||
|
await fs.writeFile(graphPath, JSON.stringify(graphData, null, 2));
|
||||||
|
console.log(`✓ Added ${blogNodes.length} blog node(s) to graph`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bundle all JavaScript files into a single bundle.js using esbuild
|
||||||
|
*/
|
||||||
|
async function bundleJavaScript() {
|
||||||
|
const entryPointMain = path.join(__dirname, 'website', 'scripts', 'main.js');
|
||||||
|
const entryPointBlog = path.join(__dirname, 'website', 'scripts', 'blogPage.js');
|
||||||
|
const outdir = path.join(__dirname, 'build', 'scripts');
|
||||||
|
const scriptsDir = path.join(__dirname, 'build', 'scripts');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await esbuild.build({
|
||||||
|
entryPoints: [entryPointMain, entryPointBlog],
|
||||||
|
bundle: true,
|
||||||
|
outdir: outdir,
|
||||||
|
entryNames: '[name].bundle',
|
||||||
|
format: 'esm',
|
||||||
|
platform: 'browser',
|
||||||
|
target: 'es2022',
|
||||||
|
minify: true,
|
||||||
|
sourcemap: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✓ Bundled JavaScript with esbuild');
|
||||||
|
|
||||||
|
// Remove all individual JS files except bundle.js
|
||||||
|
async function removeJsFiles(dir) {
|
||||||
|
const items = await fs.readdir(dir);
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const fullPath = path.join(dir, item);
|
||||||
|
const stats = await fs.stat(fullPath);
|
||||||
|
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
await removeJsFiles(fullPath);
|
||||||
|
// Remove empty directory
|
||||||
|
await fs.rmdir(fullPath);
|
||||||
|
} else if (item.endsWith('.js') && !item.endsWith('.bundle.js')) {
|
||||||
|
await fs.unlink(fullPath);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
await fs.copyFile(src, dest);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await copyRecursive(websiteDir, buildDir);
|
await removeJsFiles(scriptsDir);
|
||||||
console.log("Website files copied successfully.");
|
console.log('✓ Removed individual JS files');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error copying website files:', error);
|
console.error('✗ JavaScript bundling failed:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildPage(pageName, outputName) {
|
// Copy files recursively from website to build
|
||||||
const layoutPath = path.join(__dirname, 'templates', '_layout.html');
|
async function copyWebsiteFiles() {
|
||||||
const commonHeaderPath = path.join(__dirname, 'templates', '_header.html');
|
const websiteDir = path.join(__dirname, 'website');
|
||||||
const commonFooterPath = path.join(__dirname, 'templates', '_footer.html');
|
const buildDir = path.join(__dirname, 'build');
|
||||||
const contentPath = path.join(__dirname, 'src', `${pageName}.html`);
|
|
||||||
const outputDir = path.join(__dirname, 'build');
|
|
||||||
const outputPath = path.join(outputDir, `${outputName}.html`);
|
|
||||||
|
|
||||||
try {
|
console.log('Building site from website/ to build/...');
|
||||||
// Ensure the build directory exists
|
|
||||||
await fs.mkdir(outputDir, { recursive: true });
|
|
||||||
|
|
||||||
let layoutContent = await fs.readFile(layoutPath, 'utf-8');
|
async function copyRecursive(src, dest) {
|
||||||
const srcFileContent = await fs.readFile(contentPath, 'utf-8');
|
const stats = await fs.stat(src);
|
||||||
|
|
||||||
// Extract metadata
|
if (stats.isDirectory()) {
|
||||||
const metadataRegex = /<!--\s*METADATA:([\s\S]*?)-->/;
|
await fs.mkdir(dest, { recursive: true });
|
||||||
const metadataMatch = srcFileContent.match(metadataRegex);
|
const items = await fs.readdir(src);
|
||||||
let metadata = {};
|
|
||||||
let mainPageContent = srcFileContent;
|
|
||||||
|
|
||||||
if (metadataMatch && metadataMatch[1]) {
|
for (const item of items) {
|
||||||
try {
|
const srcPath = path.join(src, item);
|
||||||
metadata = JSON.parse(metadataMatch[1].trim());
|
const destPath = path.join(dest, item);
|
||||||
} catch (e) {
|
await copyRecursive(srcPath, destPath);
|
||||||
console.error(`Error parsing metadata JSON for ${pageName}.html:`, e);
|
|
||||||
}
|
}
|
||||||
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 {
|
try {
|
||||||
// Copy website files first
|
// Clean build directory
|
||||||
await copyWebsiteFiles();
|
try {
|
||||||
|
await fs.rm(buildDir, { recursive: true, force: true });
|
||||||
const srcDir = path.join(__dirname, 'src');
|
} catch (err) {
|
||||||
const files = await fs.readdir(srcDir);
|
// Directory might not exist, that's okay
|
||||||
|
|
||||||
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) {
|
// Copy website files
|
||||||
const pageName = path.parse(file).name;
|
await copyRecursive(websiteDir, buildDir);
|
||||||
const outputName = pageName;
|
console.log('✓ Build complete! Files copied to build/');
|
||||||
await buildPage(pageName, outputName);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("All pages built successfully.");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Build process failed:', error);
|
console.error('✗ Build failed:', error);
|
||||||
process.exit(1);
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strips markdown syntax to produce plain-text for descriptions.
|
||||||
|
*
|
||||||
|
* @param {string} md
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function mdToPlain(md) {
|
||||||
|
return (md || '')
|
||||||
|
.replace(/```[\s\S]*?```/g, '')
|
||||||
|
.replace(/`[^`]+`/g, '')
|
||||||
|
.replace(/!\[.*?\]\(.*?\)/g, '')
|
||||||
|
.replace(/\[([^\]]+)\]\(.*?\)/g, '$1')
|
||||||
|
.replace(/^#{1,6}\s+/gm, '')
|
||||||
|
.replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, '$1')
|
||||||
|
.replace(/^[-*_]{3,}$/gm, '')
|
||||||
|
.replace(/\n{2,}/g, ' ')
|
||||||
|
.replace(/\n/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.slice(0, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = posts.map(post => {
|
||||||
|
const url = `${siteUrl}/blog/${encodeURIComponent(post.slug)}/`;
|
||||||
|
const pubDate = post.date ? new Date(post.date).toUTCString() : '';
|
||||||
|
const description = xmlEscape(mdToPlain(post.content));
|
||||||
|
return [
|
||||||
|
' <item>',
|
||||||
|
` <title>${xmlEscape(post.title || post.slug)}</title>`,
|
||||||
|
` <link>${url}</link>`,
|
||||||
|
` <guid isPermaLink="true">${url}</guid>`,
|
||||||
|
pubDate ? ` <pubDate>${pubDate}</pubDate>` : '',
|
||||||
|
post.author ? ` <author>${xmlEscape(post.author)}</author>` : '',
|
||||||
|
` <description>${description}</description>`,
|
||||||
|
' </item>',
|
||||||
|
].filter(Boolean).join('\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
const xml = [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
'<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">',
|
||||||
|
' <channel>',
|
||||||
|
' <title>lit.ruv.wtf</title>',
|
||||||
|
` <link>${siteUrl}</link>`,
|
||||||
|
' <description>Blog posts from lit.ruv.wtf</description>',
|
||||||
|
' <language>en-us</language>',
|
||||||
|
` <atom:link href="${feedUrl}" rel="self" type="application/rss+xml" />`,
|
||||||
|
...items,
|
||||||
|
' </channel>',
|
||||||
|
'</rss>',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
await fs.writeFile(path.join(buildDir, 'rss.xml'), xml, 'utf-8');
|
||||||
|
console.log('✓ Generated RSS feed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a sitemap.xml from static routes and blog posts.
|
||||||
|
*
|
||||||
|
* @param {Array<{slug: string, date: string|null}>} posts
|
||||||
|
* @param {string} buildDir
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function generateSitemap(posts, buildDir) {
|
||||||
|
const siteUrl = 'https://lit.ruv.wtf';
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} loc
|
||||||
|
* @param {{ lastmod?: string, priority?: string }} opts
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function urlEntry(loc, { lastmod = today, priority = '0.80' } = {}) {
|
||||||
|
return [
|
||||||
|
' <url>',
|
||||||
|
` <loc>${loc}</loc>`,
|
||||||
|
` <lastmod>${lastmod}</lastmod>`,
|
||||||
|
` <priority>${priority}</priority>`,
|
||||||
|
' </url>',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const staticEntries = [
|
||||||
|
urlEntry(`${siteUrl}/`, { priority: '1.00' }),
|
||||||
|
urlEntry(`${siteUrl}/blog.html`, { priority: '0.90' }),
|
||||||
|
urlEntry(`${siteUrl}/docs/sitemap.xml`, { priority: '0.80' }),
|
||||||
|
urlEntry(`${siteUrl}/materials/`, { priority: '0.80' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const postEntries = posts.map(post => {
|
||||||
|
const lastmod = post.date ? new Date(post.date).toISOString().slice(0, 10) : today;
|
||||||
|
return urlEntry(`${siteUrl}/blog/${encodeURIComponent(post.slug)}/`, { lastmod, priority: '0.50' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const xml = [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"',
|
||||||
|
' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"',
|
||||||
|
' xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9',
|
||||||
|
' http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">',
|
||||||
|
...staticEntries,
|
||||||
|
...postEntries,
|
||||||
|
'</urlset>',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
await fs.writeFile(path.join(buildDir, 'sitemap.xml'), xml, 'utf-8');
|
||||||
|
console.log(`✓ Generated sitemap with ${staticEntries.length + postEntries.length} URL(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the build
|
||||||
|
async function build() {
|
||||||
|
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
39
package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
349
src/mining.html
@@ -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>
|
|
||||||
@@ -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 & 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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<footer>
|
|
||||||
<a href="privacy.html">Privacy Policy</a>
|
|
||||||
<p>© 2013-<span id="current-year"></span> Max Litruv Boonzaayer</p>
|
|
||||||
<script>
|
|
||||||
document.getElementById('current-year').textContent = new Date().getFullYear();
|
|
||||||
</script>
|
|
||||||
</footer>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
360
tools/StaticBlogGenerator.js
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const path = require('path');
|
||||||
|
const { renderNavLinkItems } = require('./navLinks');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates static blog pages and an index page from markdown post data.
|
||||||
|
*/
|
||||||
|
class StaticBlogGenerator {
|
||||||
|
/**
|
||||||
|
* @param {{ siteRootDir: string }} options
|
||||||
|
*/
|
||||||
|
constructor(options) {
|
||||||
|
/** @type {string} */
|
||||||
|
this.siteRootDir = options.siteRootDir;
|
||||||
|
/** @type {string} */
|
||||||
|
this.blogRootDir = path.join(this.siteRootDir, 'blog');
|
||||||
|
/** @type {string} */
|
||||||
|
this.blogIndexPath = path.join(this.siteRootDir, 'blog.html');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates blog index and post pages.
|
||||||
|
*
|
||||||
|
* @param {Array<{slug: string, title: string, date: string | null, author: string | null, tags?: string[], content: string}>} posts
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async generate(posts) {
|
||||||
|
await fs.rm(this.blogRootDir, { recursive: true, force: true });
|
||||||
|
await fs.mkdir(this.blogRootDir, { recursive: true });
|
||||||
|
|
||||||
|
const indexHtml = this.renderIndexPage(posts);
|
||||||
|
await fs.writeFile(this.blogIndexPath, indexHtml, 'utf-8');
|
||||||
|
|
||||||
|
for (let i = 0; i < posts.length; i++) {
|
||||||
|
const post = posts[i];
|
||||||
|
const prev = posts[i + 1] || null;
|
||||||
|
const next = posts[i - 1] || null;
|
||||||
|
const postDir = path.join(this.blogRootDir, post.slug);
|
||||||
|
await fs.mkdir(postDir, { recursive: true });
|
||||||
|
const postHtml = this.renderPostPage(post, prev, next);
|
||||||
|
await fs.writeFile(path.join(postDir, 'index.html'), postHtml, 'utf-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Array<{slug: string, title: string, date: string | null, author: string | null, tags?: string[]}>} posts
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
renderIndexPage(posts) {
|
||||||
|
const cardsHtml = posts.map((post) => {
|
||||||
|
const dateLabel = post.date ? this.formatDate(post.date) : 'Undated';
|
||||||
|
const authorLabel = post.author ? this.escapeHtml(post.author) : 'Unknown author';
|
||||||
|
const safeTitle = this.escapeHtml(post.title || post.slug);
|
||||||
|
const safeSlug = encodeURIComponent(post.slug);
|
||||||
|
const tags = Array.isArray(post.tags) ? post.tags : [];
|
||||||
|
const tagsHtml = tags.length > 0
|
||||||
|
? `<div class="blog-index-tags">${tags.map((tag) => `<span class="blog-tag">${this.escapeHtml(tag)}</span>`).join('')}</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'<article class="blog-index-item">',
|
||||||
|
` <h2 class="blog-index-item-title"><a href="/blog/${safeSlug}/">${safeTitle}</a></h2>`,
|
||||||
|
` <p class="blog-index-item-meta">${dateLabel} <span aria-hidden="true">-</span> ${authorLabel}</p>`,
|
||||||
|
tagsHtml,
|
||||||
|
'</article>'
|
||||||
|
].join('\n');
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
const content = [
|
||||||
|
'<main class="blog-shell">',
|
||||||
|
this.renderBrandBar(),
|
||||||
|
'<section class="blog-card" aria-labelledby="blog-index-title">',
|
||||||
|
' <header class="blog-post-header">',
|
||||||
|
' <h1 id="blog-index-title" class="blog-post-title">Blog</h1>',
|
||||||
|
' <p class="blog-index-subtitle">Direct links to every post.</p>',
|
||||||
|
' </header>',
|
||||||
|
` <div class="blog-index-list">${cardsHtml || '<p class="blog-empty">No posts yet.</p>'}</div>`,
|
||||||
|
'</section>',
|
||||||
|
'</main>'
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
return this.renderPageTemplate('Blog', content, '/blog.html');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts a plain-text description from the first paragraph of markdown content.
|
||||||
|
*
|
||||||
|
* @param {string} content
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
extractDescription(content) {
|
||||||
|
const lines = (content || '').split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('!') || trimmed.startsWith('>') || /^[-*_]{3,}$/.test(trimmed) || trimmed.startsWith('```')) continue;
|
||||||
|
const plain = trimmed.replace(/[*_`\[\]]/g, '').replace(/!?\[.*?\]\(.*?\)/g, '').trim();
|
||||||
|
if (plain.length > 20) return plain.slice(0, 400);
|
||||||
|
}
|
||||||
|
return 'Read this post on lit.ruv.wtf';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{slug: string, title: string, date: string | null, author: string | null, tags?: string[], content: string}} post
|
||||||
|
* @param {{slug: string, title: string, date: string | null} | null} prev - Older post (lower index)
|
||||||
|
* @param {{slug: string, title: string, date: string | null} | null} next - Newer post (higher index)
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
renderPostPage(post, prev = null, next = null) {
|
||||||
|
const safeTitle = this.escapeHtml(post.title || post.slug);
|
||||||
|
const dateLabel = post.date ? this.formatDate(post.date) : null;
|
||||||
|
const authorLabel = post.author ? this.escapeHtml(post.author) : null;
|
||||||
|
const tags = Array.isArray(post.tags) ? post.tags : [];
|
||||||
|
|
||||||
|
const metaBits = [];
|
||||||
|
if (dateLabel) metaBits.push(`<span>${dateLabel}</span>`);
|
||||||
|
if (authorLabel) metaBits.push(`<span>${authorLabel}</span>`);
|
||||||
|
|
||||||
|
const tagsHtml = tags.length > 0
|
||||||
|
? `<div class="blog-post-tags">${tags.map((tag) => `<span class="blog-tag">${this.escapeHtml(tag)}</span>`).join('')}</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const markdownJson = JSON.stringify(post.content || '').replace(/<\//g, '<\\/');
|
||||||
|
|
||||||
|
const prevPeek = prev
|
||||||
|
? [
|
||||||
|
`<div class="blog-post-peek-wrapper blog-post-peek-wrapper--prev">`,
|
||||||
|
`<a class="blog-post-peek blog-post-peek--prev" href="/blog/${encodeURIComponent(prev.slug)}/" aria-label="Older post: ${this.escapeHtml(prev.title)}">`,
|
||||||
|
` <div class="blog-post-peek-header">`,
|
||||||
|
` <span class="blog-post-peek-direction">← older</span>`,
|
||||||
|
` <span class="blog-post-peek-pin" data-peek-pin="prev-out"></span>`,
|
||||||
|
` </div>`,
|
||||||
|
` <div class="blog-post-peek-body">`,
|
||||||
|
` <span class="blog-post-peek-title">${this.escapeHtml(prev.title)}</span>`,
|
||||||
|
prev.date ? ` <span class="blog-post-peek-date">${this.formatDate(prev.date)}</span>` : '',
|
||||||
|
` </div>`,
|
||||||
|
`</a>`,
|
||||||
|
`</div>`,
|
||||||
|
].join('\n')
|
||||||
|
: '<div class="blog-post-peek-wrapper blog-post-peek-wrapper--prev"></div>';
|
||||||
|
|
||||||
|
const nextPeek = next
|
||||||
|
? [
|
||||||
|
`<div class="blog-post-peek-wrapper blog-post-peek-wrapper--next">`,
|
||||||
|
`<a class="blog-post-peek blog-post-peek--next" href="/blog/${encodeURIComponent(next.slug)}/" aria-label="Newer post: ${this.escapeHtml(next.title)}">`,
|
||||||
|
` <div class="blog-post-peek-header">`,
|
||||||
|
` <span class="blog-post-peek-pin" data-peek-pin="next-in"></span>`,
|
||||||
|
` <span class="blog-post-peek-direction" style="text-align:right">newer →</span>`,
|
||||||
|
` </div>`,
|
||||||
|
` <div class="blog-post-peek-body">`,
|
||||||
|
` <span class="blog-post-peek-title">${this.escapeHtml(next.title)}</span>`,
|
||||||
|
next.date ? ` <span class="blog-post-peek-date">${this.formatDate(next.date)}</span>` : '',
|
||||||
|
` </div>`,
|
||||||
|
`</a>`,
|
||||||
|
`</div>`,
|
||||||
|
].join('\n')
|
||||||
|
: '<div class="blog-post-peek-wrapper blog-post-peek-wrapper--next"></div>';
|
||||||
|
|
||||||
|
const content = [
|
||||||
|
'<main class="blog-shell">',
|
||||||
|
this.renderBrandBar(),
|
||||||
|
'<div class="blog-post-layout">',
|
||||||
|
prevPeek,
|
||||||
|
'<article class="blog-card" aria-labelledby="blog-post-title">',
|
||||||
|
' <header class="blog-post-header">',
|
||||||
|
` <h1 id="blog-post-title" class="blog-post-title">${safeTitle}</h1>`,
|
||||||
|
metaBits.length > 0 ? ` <p class="blog-post-meta">${metaBits.join('<span aria-hidden="true">-</span>')}</p>` : '',
|
||||||
|
tagsHtml,
|
||||||
|
' </header>',
|
||||||
|
'<div class="md-hr" role="separator"><span class="md-hr-line"></span><span class="md-hr-arrow"></span></div>',
|
||||||
|
' <section class="blog-post-content" data-blog-post-content></section>',
|
||||||
|
' <section class="blog-comments" data-blog-comments data-matrix-homeserver="https://chat.ruv.wtf" data-matrix-room="#general:chat.ruv.wtf"></section>',
|
||||||
|
` <script id="blogPostMarkdown" type="application/json">${markdownJson}</script>`,
|
||||||
|
'</article>',
|
||||||
|
nextPeek,
|
||||||
|
'<svg class="blog-post-splines" aria-hidden="true"></svg>',
|
||||||
|
'</div>',
|
||||||
|
'</main>'
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const canonicalUrl = `https://lit.ruv.wtf/blog/${encodeURIComponent(post.slug)}/`;
|
||||||
|
return this.renderPageTemplate(`${safeTitle} - Blog`, content, `/blog/${encodeURIComponent(post.slug)}/`, {
|
||||||
|
description: this.extractDescription(post.content),
|
||||||
|
ogTitle: safeTitle,
|
||||||
|
ogType: 'article',
|
||||||
|
ogUrl: canonicalUrl,
|
||||||
|
articlePublishedTime: post.date || null,
|
||||||
|
articleAuthor: post.author || null,
|
||||||
|
articleTags: tags,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} title
|
||||||
|
* @param {string} bodyHtml
|
||||||
|
* @param {string} canonicalPath
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
renderPageTemplate(title, bodyHtml, canonicalPath, og = {}) {
|
||||||
|
const escapedTitle = this.escapeHtml(title);
|
||||||
|
const canonicalUrl = `https://lit.ruv.wtf${canonicalPath}`;
|
||||||
|
const description = this.escapeHtml(og.description || 'lit.ruv.wtf blog posts.');
|
||||||
|
const ogTitle = this.escapeHtml(og.ogTitle || title);
|
||||||
|
const ogType = og.ogType || 'website';
|
||||||
|
const ogUrl = og.ogUrl || canonicalUrl;
|
||||||
|
|
||||||
|
const ogImage = og.ogImage || 'https://lit.ruv.wtf/logos/512px.png';
|
||||||
|
const ogMeta = [
|
||||||
|
` <meta property="og:title" content="${ogTitle}">`,
|
||||||
|
` <meta property="og:type" content="${ogType}">`,
|
||||||
|
` <meta property="og:url" content="${ogUrl}">`,
|
||||||
|
` <meta property="og:description" content="${description}">`,
|
||||||
|
` <meta property="og:image" content="${ogImage}">`,
|
||||||
|
` <meta property="og:site_name" content="lit.ruv.wtf">`,
|
||||||
|
` <meta name="twitter:card" content="summary">`,
|
||||||
|
` <meta name="twitter:title" content="${ogTitle}">`,
|
||||||
|
` <meta name="twitter:description" content="${description}">`,
|
||||||
|
og.articlePublishedTime ? ` <meta property="article:published_time" content="${og.articlePublishedTime}">` : '',
|
||||||
|
og.articleAuthor ? ` <meta property="article:author" content="${this.escapeHtml(og.articleAuthor)}">` : '',
|
||||||
|
...(og.articleTags || []).map(t => ` <meta property="article:tag" content="${this.escapeHtml(t)}">`,),
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'<!DOCTYPE html>',
|
||||||
|
'<html lang="en">',
|
||||||
|
'<head>',
|
||||||
|
' <meta charset="UTF-8">',
|
||||||
|
' <meta name="viewport" content="width=device-width, initial-scale=1.0">',
|
||||||
|
' <base href="/">',
|
||||||
|
` <title>${escapedTitle}</title>`,
|
||||||
|
` <meta name="description" content="${description}">`,
|
||||||
|
` <link rel="canonical" href="${canonicalUrl}">`,
|
||||||
|
...ogMeta,
|
||||||
|
` <link rel="alternate" type="application/rss+xml" title="lit.ruv.wtf RSS" href="/rss.xml">`,
|
||||||
|
' <link rel="icon" type="image/png" sizes="32x32" href="/logos/32px.png">',
|
||||||
|
' <link rel="icon" type="image/png" sizes="64x64" href="/logos/64px.png">',
|
||||||
|
' <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css">',
|
||||||
|
' <link rel="stylesheet" href="/styles/main.css">',
|
||||||
|
' <script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>',
|
||||||
|
' <script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-javascript.min.js"></script>',
|
||||||
|
' <script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js"></script>',
|
||||||
|
' <script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-typescript.min.js"></script>',
|
||||||
|
' <script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-jsx.min.js"></script>',
|
||||||
|
' <script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-css.min.js"></script>',
|
||||||
|
' <script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>',
|
||||||
|
' <script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-bash.min.js"></script>',
|
||||||
|
'</head>',
|
||||||
|
'<body class="blog-page">',
|
||||||
|
'<nav class="quick-links" aria-label="Primary">',
|
||||||
|
' <div class="quick-links-inner">',
|
||||||
|
this.renderHeaderLinks(),
|
||||||
|
' <a class="quick-link rss-link" href="/rss.xml" aria-label="RSS feed" title="RSS feed">',
|
||||||
|
' <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M6.18 15.64a2.18 2.18 0 0 1 2.18 2.18C8.36 19.01 7.38 20 6.18 20C4.98 20 4 19.01 4 17.82a2.18 2.18 0 0 1 2.18-2.18M4 4.44A15.56 15.56 0 0 1 19.56 20h-2.83A12.73 12.73 0 0 0 4 7.27V4.44m0 5.66a9.9 9.9 0 0 1 9.9 9.9h-2.83A7.07 7.07 0 0 0 4 12.93V10.1z"/></svg>',
|
||||||
|
' RSS',
|
||||||
|
' </a>',
|
||||||
|
' </div>',
|
||||||
|
'</nav>',
|
||||||
|
this.renderScrollTopBar(),
|
||||||
|
bodyHtml,
|
||||||
|
this.renderFooter(),
|
||||||
|
' <script type="module" src="/scripts/blogPage.js"></script>',
|
||||||
|
'</body>',
|
||||||
|
'</html>'
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
renderHeaderLinks() {
|
||||||
|
return renderNavLinkItems(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
renderScrollTopBar() {
|
||||||
|
return [
|
||||||
|
'<header class="blog-scroll-topbar" data-blog-scroll-topbar>',
|
||||||
|
' <div class="blog-scroll-topbar-inner">',
|
||||||
|
' <a class="blog-scroll-logo-link" href="/" aria-label="Back to main site">',
|
||||||
|
' <img src="/logos/LogoFull.svg" alt="lit.ruv.wtf" class="blog-scroll-logo" />',
|
||||||
|
' </a>',
|
||||||
|
' <nav class="blog-scroll-links" aria-label="Scrolled navigation">',
|
||||||
|
this.renderHeaderLinks(),
|
||||||
|
' </nav>',
|
||||||
|
' </div>',
|
||||||
|
'</header>'
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
renderFooter() {
|
||||||
|
return [
|
||||||
|
'<footer class="blog-footer">',
|
||||||
|
' <div class="blog-footer-inner">',
|
||||||
|
' <a href="/" class="blog-footer-logo" aria-label="Back to main site">',
|
||||||
|
' <img src="/logos/LogoFull.svg" alt="lit.ruv.wtf" class="blog-footer-logo-img" />',
|
||||||
|
' </a>',
|
||||||
|
' <div class="blog-footer-links">',
|
||||||
|
this.renderHeaderLinks(),
|
||||||
|
' <a class="quick-link rss-link" href="/rss.xml" aria-label="RSS feed" title="RSS feed">',
|
||||||
|
' <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M6.18 15.64a2.18 2.18 0 0 1 2.18 2.18C8.36 19.01 7.38 20 6.18 20C4.98 20 4 19.01 4 17.82a2.18 2.18 0 0 1 2.18-2.18M4 4.44A15.56 15.56 0 0 1 19.56 20h-2.83A12.73 12.73 0 0 0 4 7.27V4.44m0 5.66a9.9 9.9 0 0 1 9.9 9.9h-2.83A7.07 7.07 0 0 0 4 12.93V10.1z"/></svg>',
|
||||||
|
' RSS',
|
||||||
|
' </a>',
|
||||||
|
' </div>',
|
||||||
|
` <p class="blog-footer-copy">© ${new Date().getFullYear()} lit.ruv.wtf</p>`,
|
||||||
|
' </div>',
|
||||||
|
'</footer>',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
renderBrandBar() {
|
||||||
|
return [
|
||||||
|
'<a class="blog-logo-link" href="/" aria-label="Back to main site">',
|
||||||
|
' <img src="/logos/LogoFull.svg" alt="lit.ruv.wtf" class="blog-logo" />',
|
||||||
|
'</a>'
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} dateInput
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
formatDate(dateInput) {
|
||||||
|
const parsed = new Date(dateInput);
|
||||||
|
if (Number.isNaN(parsed.valueOf())) {
|
||||||
|
return this.escapeHtml(dateInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
}).format(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} value
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
escapeHtml(value) {
|
||||||
|
return String(value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
StaticBlogGenerator
|
||||||
|
};
|
||||||
4
tools/blogeditor/.vscodeignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# Exclude source/build artifacts — keep only what's needed in the VSIX
|
||||||
|
build-dist.js
|
||||||
|
*.vsix
|
||||||
|
.gitignore
|
||||||
BIN
tools/blogeditor/blog-editor-0.1.12.vsix
Normal file
BIN
tools/blogeditor/blog-editor.vsix
Normal file
102
tools/blogeditor/build-dist.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-builds the Milkdown Crepe JS + CSS bundles into .vscode/dist/
|
||||||
|
* Run this before packaging the VSIX: node .vscode/build-dist.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
// Resolve symlinks so node_modules lookup works even in junction'd paths
|
||||||
|
const ROOT = fs.realpathSync(__dirname);
|
||||||
|
const DIST_DIR = path.join(ROOT, 'dist');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks up from startDir to find node_modules containing a given package.
|
||||||
|
* Returns null if not found (never throws).
|
||||||
|
* @param {string} pkg
|
||||||
|
* @param {string} startDir
|
||||||
|
* @returns {string | null}
|
||||||
|
*/
|
||||||
|
function resolveFromNearestNodeModules(pkg, startDir) {
|
||||||
|
let dir = startDir;
|
||||||
|
while (true) {
|
||||||
|
const candidate = path.join(dir, 'node_modules', pkg);
|
||||||
|
if (fs.existsSync(candidate)) return candidate;
|
||||||
|
const parent = path.dirname(dir);
|
||||||
|
if (parent === dir) return null;
|
||||||
|
dir = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk up from __dirname until we find node_modules with esbuild.
|
||||||
|
// Also tries process.cwd() in case the script is in a stub folder without node_modules.
|
||||||
|
const esbuildEntry = resolveFromNearestNodeModules('esbuild', ROOT)
|
||||||
|
?? resolveFromNearestNodeModules('esbuild', process.cwd());
|
||||||
|
if (!esbuildEntry) throw new Error('Cannot find esbuild. Run this from the project root that contains node_modules.');
|
||||||
|
const esbuild = require(path.join(esbuildEntry, 'lib', 'main.js'));
|
||||||
|
const crepeLib = resolveFromNearestNodeModules('@milkdown/crepe', ROOT)
|
||||||
|
?? resolveFromNearestNodeModules('@milkdown/crepe', process.cwd());
|
||||||
|
const CREPE_DIR = path.join(crepeLib, 'lib', 'theme');
|
||||||
|
// Also need the root for esbuild resolveDir (must contain node_modules/@milkdown)
|
||||||
|
const ROOT_DIR = path.dirname(path.dirname(esbuildEntry));
|
||||||
|
|
||||||
|
const FONT_LOADERS = {
|
||||||
|
'.woff2': 'dataurl',
|
||||||
|
'.woff': 'dataurl',
|
||||||
|
'.ttf': 'dataurl',
|
||||||
|
'.eot': 'dataurl',
|
||||||
|
'.svg': 'dataurl',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TEXT_LOADERS = {
|
||||||
|
'.aff': 'text',
|
||||||
|
'.dic': 'text',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
fs.mkdirSync(DIST_DIR, { recursive: true });
|
||||||
|
|
||||||
|
console.log('⏳ Bundling Milkdown JS…');
|
||||||
|
const jsResult = await esbuild.build({
|
||||||
|
stdin: { contents: `export { Crepe, CrepeFeature } from '@milkdown/crepe';`, resolveDir: ROOT_DIR, loader: 'js' },
|
||||||
|
bundle: true, format: 'esm', platform: 'browser', target: 'es2022', minify: true, write: false,
|
||||||
|
});
|
||||||
|
fs.writeFileSync(path.join(DIST_DIR, 'milkdown.js'), jsResult.outputFiles[0].contents);
|
||||||
|
console.log(`✓ milkdown.js ${(jsResult.outputFiles[0].contents.byteLength / 1024).toFixed(0)} KB`);
|
||||||
|
|
||||||
|
console.log('⏳ Bundling common CSS…');
|
||||||
|
const cssCommon = await esbuild.build({
|
||||||
|
entryPoints: [path.join(CREPE_DIR, 'common', 'style.css')],
|
||||||
|
bundle: true, write: false, loader: FONT_LOADERS,
|
||||||
|
});
|
||||||
|
fs.writeFileSync(path.join(DIST_DIR, 'milkdown-common.css'), cssCommon.outputFiles[0].contents);
|
||||||
|
console.log(`✓ milkdown-common.css ${(cssCommon.outputFiles[0].contents.byteLength / 1024).toFixed(0)} KB`);
|
||||||
|
|
||||||
|
console.log('⏳ Bundling theme CSS…');
|
||||||
|
const cssTheme = await esbuild.build({
|
||||||
|
entryPoints: [path.join(CREPE_DIR, 'frame-dark', 'style.css')],
|
||||||
|
bundle: true, write: false, loader: FONT_LOADERS,
|
||||||
|
});
|
||||||
|
fs.writeFileSync(path.join(DIST_DIR, 'milkdown-theme.css'), cssTheme.outputFiles[0].contents);
|
||||||
|
console.log(`✓ milkdown-theme.css ${(cssTheme.outputFiles[0].contents.byteLength / 1024).toFixed(0)} KB`);
|
||||||
|
|
||||||
|
console.log('⏳ Bundling spellcheck JS…');
|
||||||
|
const spellcheckJs = await esbuild.build({
|
||||||
|
entryPoints: [path.join(ROOT, 'spellcheck.js')],
|
||||||
|
bundle: true,
|
||||||
|
format: 'esm',
|
||||||
|
platform: 'browser',
|
||||||
|
target: 'es2022',
|
||||||
|
minify: true,
|
||||||
|
write: false,
|
||||||
|
loader: TEXT_LOADERS,
|
||||||
|
});
|
||||||
|
fs.writeFileSync(path.join(DIST_DIR, 'spellcheck.js'), spellcheckJs.outputFiles[0].contents);
|
||||||
|
console.log(`✓ spellcheck.js ${(spellcheckJs.outputFiles[0].contents.byteLength / 1024).toFixed(0)} KB`);
|
||||||
|
|
||||||
|
console.log('\n✓ dist/ ready — run: npx @vscode/vsce package --out blog-editor.vsix --no-dependencies');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(err => { console.error(err); process.exit(1); });
|
||||||
44
tools/blogeditor/bump-version.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const PACKAGE_JSON_PATH = path.join(__dirname, 'package.json');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a semantic version string and bumps the patch number.
|
||||||
|
* @param {string} version
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function bumpPatchVersion(version) {
|
||||||
|
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`Invalid version format: "${version}". Expected x.y.z`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const major = Number(match[1]);
|
||||||
|
const minor = Number(match[2]);
|
||||||
|
const patch = Number(match[3]) + 1;
|
||||||
|
return `${major}.${minor}.${patch}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads package.json, increments patch version, and writes the updated file.
|
||||||
|
*/
|
||||||
|
function main() {
|
||||||
|
const raw = fs.readFileSync(PACKAGE_JSON_PATH, 'utf8');
|
||||||
|
const pkg = JSON.parse(raw);
|
||||||
|
|
||||||
|
if (typeof pkg.version !== 'string') {
|
||||||
|
throw new Error('package.json is missing a valid string version field.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = pkg.version;
|
||||||
|
const next = bumpPatchVersion(previous);
|
||||||
|
pkg.version = next;
|
||||||
|
|
||||||
|
fs.writeFileSync(PACKAGE_JSON_PATH, `${JSON.stringify(pkg, null, 4)}\n`, 'utf8');
|
||||||
|
console.log(`[blog-editor] version bumped: ${previous} -> ${next}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
1678
tools/blogeditor/editor.html
Normal file
991
tools/blogeditor/extension.js
Normal file
@@ -0,0 +1,991 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const vscode = require('vscode');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const extensionManifest = require('./package.json');
|
||||||
|
|
||||||
|
const EXT_DIR = __dirname;
|
||||||
|
const DIST_DIR = path.join(EXT_DIR, 'dist');
|
||||||
|
const ROOT_DIR = path.resolve(EXT_DIR, '..');
|
||||||
|
const EXTENSION_VERSION = extensionManifest.version;
|
||||||
|
|
||||||
|
/** @type {vscode.WebviewPanel | undefined} */
|
||||||
|
let panel;
|
||||||
|
|
||||||
|
/** @type {string | undefined} */
|
||||||
|
let activeBlogDir;
|
||||||
|
|
||||||
|
/** @type {string | undefined} */
|
||||||
|
let pendingOpenSlug;
|
||||||
|
|
||||||
|
/** @type {BlogSidebarProvider | undefined} */
|
||||||
|
let sidebarProvider;
|
||||||
|
|
||||||
|
/** @type {vscode.OutputChannel | undefined} */
|
||||||
|
let output;
|
||||||
|
|
||||||
|
const DEFAULT_LM_STUDIO_BASE_URL = 'http://127.0.0.1:1234';
|
||||||
|
const LM_STUDIO_CHAT_ENDPOINTS = ['/v1/chat/completions'];
|
||||||
|
const LM_STUDIO_MODELS_ENDPOINTS = ['/api/v1/models', '/v1/models'];
|
||||||
|
const BLOG_TAGS_JSON_SCHEMA = {
|
||||||
|
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||||
|
title: 'Blog Tags',
|
||||||
|
type: 'array',
|
||||||
|
maxItems: 20,
|
||||||
|
uniqueItems: true,
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
minLength: 1,
|
||||||
|
maxLength: 32,
|
||||||
|
pattern: '^[a-z0-9]+(-[a-z0-9]+)*$',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @implements {vscode.TreeDataProvider<vscode.TreeItem>}
|
||||||
|
*/
|
||||||
|
class BlogSidebarProvider {
|
||||||
|
constructor() {
|
||||||
|
this._onDidChangeTreeData = new vscode.EventEmitter();
|
||||||
|
this.onDidChangeTreeData = this._onDidChangeTreeData.event;
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh() {
|
||||||
|
this._onDidChangeTreeData.fire(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {vscode.ProviderResult<vscode.TreeItem[]>}
|
||||||
|
*/
|
||||||
|
getChildren() {
|
||||||
|
if (!activeBlogDir || !fs.existsSync(activeBlogDir)) {
|
||||||
|
const openItem = new vscode.TreeItem('Open Blog Editor', vscode.TreeItemCollapsibleState.None);
|
||||||
|
openItem.tooltip = 'Open the Blog Editor panel';
|
||||||
|
openItem.command = {
|
||||||
|
command: 'blogEditor.open',
|
||||||
|
title: 'Open Blog Editor'
|
||||||
|
};
|
||||||
|
openItem.iconPath = new vscode.ThemeIcon('edit');
|
||||||
|
return [openItem];
|
||||||
|
}
|
||||||
|
|
||||||
|
const posts = fs.readdirSync(activeBlogDir)
|
||||||
|
.filter(f => f.endsWith('.md') && !f.startsWith('.'))
|
||||||
|
.map(f => {
|
||||||
|
const slug = path.basename(f, '.md');
|
||||||
|
const meta = parseFrontmatterFromFile(path.join(activeBlogDir, f));
|
||||||
|
return { slug, title: meta.title || slug, date: meta.date || '' };
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.date && b.date) return b.date.localeCompare(a.date);
|
||||||
|
if (a.date) return -1;
|
||||||
|
if (b.date) return 1;
|
||||||
|
return a.slug.localeCompare(b.slug);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (posts.length === 0) {
|
||||||
|
const empty = new vscode.TreeItem('No posts found', vscode.TreeItemCollapsibleState.None);
|
||||||
|
empty.iconPath = new vscode.ThemeIcon('circle-slash');
|
||||||
|
return [empty];
|
||||||
|
}
|
||||||
|
|
||||||
|
return posts.map(({ slug, title, date }) => new BlogPostItem(slug, title, date));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {vscode.TreeItem} element
|
||||||
|
* @returns {vscode.TreeItem}
|
||||||
|
*/
|
||||||
|
getTreeItem(element) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses YAML-ish frontmatter from a markdown file.
|
||||||
|
* @param {string} filePath
|
||||||
|
* @returns {{ title?: string, date?: string, author?: string, tags?: string }}
|
||||||
|
*/
|
||||||
|
function parseFrontmatterFromFile(filePath) {
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||||
|
if (!m) return {};
|
||||||
|
/** @type {Record<string, string>} */
|
||||||
|
const meta = {};
|
||||||
|
for (const line of m[1].split(/\r?\n/)) {
|
||||||
|
const i = line.indexOf(':');
|
||||||
|
if (i > 0) meta[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/^["']|["']$/g, '');
|
||||||
|
}
|
||||||
|
return meta;
|
||||||
|
} catch { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} value
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function parseDateToMs(value) {
|
||||||
|
if (!value) return 0;
|
||||||
|
const ts = Date.parse(value);
|
||||||
|
return Number.isFinite(ts) ? ts : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets best previous author by most recent post date (fallback: file mtime).
|
||||||
|
* @param {string} blogDir
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function getPreviousAuthor(blogDir) {
|
||||||
|
if (!fs.existsSync(blogDir)) return '';
|
||||||
|
const entries = fs.readdirSync(blogDir)
|
||||||
|
.filter(f => f.endsWith('.md') && !f.startsWith('.'))
|
||||||
|
.map(file => {
|
||||||
|
const filePath = path.join(blogDir, file);
|
||||||
|
const meta = parseFrontmatterFromFile(filePath);
|
||||||
|
const mtimeMs = fs.statSync(filePath).mtimeMs;
|
||||||
|
return { author: (meta.author || '').trim(), dateMs: parseDateToMs(meta.date || ''), mtimeMs };
|
||||||
|
})
|
||||||
|
.filter(item => item.author)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.dateMs !== b.dateMs) return b.dateMs - a.dateMs;
|
||||||
|
return b.mtimeMs - a.mtimeMs;
|
||||||
|
});
|
||||||
|
return entries[0]?.author || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} raw
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
function parseTagArray(raw) {
|
||||||
|
if (typeof raw !== 'string') {
|
||||||
|
if (raw && typeof raw === 'object' && Array.isArray(raw.tags)) {
|
||||||
|
return raw.tags
|
||||||
|
.map(v => String(v).trim().replace(/^#+/, '').toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleaned = raw
|
||||||
|
.replace(/^```(?:json)?/i, '')
|
||||||
|
.replace(/```$/i, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (cleaned.startsWith('{') && cleaned.endsWith('}')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(cleaned);
|
||||||
|
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.tags)) {
|
||||||
|
return parsed.tags
|
||||||
|
.map(v => String(v).trim().replace(/^#+/, '').toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to array and split parsing.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = cleaned.indexOf('[');
|
||||||
|
const end = cleaned.lastIndexOf(']');
|
||||||
|
if (start >= 0 && end > start) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(cleaned.slice(start, end + 1));
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed
|
||||||
|
.map(v => String(v).trim().replace(/^#+/, '').toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to comma/newline split.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleaned
|
||||||
|
.split(/[\n,]/)
|
||||||
|
.map(v => v.replace(/^[\s\-•*#]+/, '').trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string[]} tags
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
function normalizeGeneratedTags(tags) {
|
||||||
|
/** @type {string[]} */
|
||||||
|
const out = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const tag of tags) {
|
||||||
|
const clean = tag
|
||||||
|
.replace(/^#+/, '')
|
||||||
|
.replace(/["'`]/g, '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (!clean || seen.has(clean)) continue;
|
||||||
|
seen.add(clean);
|
||||||
|
out.push(clean);
|
||||||
|
if (out.length >= 12) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FALLBACK_TAG_RULES = [
|
||||||
|
{ tag: 'unreal-engine', patterns: [/\bunreal engine\b/i, /\bue\b/i] },
|
||||||
|
{ tag: 'game-development', patterns: [/\bgame dev\b/i, /\bgame development\b/i, /\bgamedev\b/i] },
|
||||||
|
{ tag: 'programming', patterns: [/\bprogramming\b/i, /\bdeveloper\b/i, /\bdevelopment\b/i] },
|
||||||
|
{ tag: '3d-art', patterns: [/\b3d art\b/i, /\b3d\b/i] },
|
||||||
|
{ tag: 'animation', patterns: [/\banimation\b/i, /\banimations\b/i] },
|
||||||
|
{ tag: 'systems-design', patterns: [/\bsystems design\b/i, /\binteraction systems\b/i, /\bsystems-heavy\b/i] },
|
||||||
|
{ tag: 'tools', patterns: [/\btools\b/i, /\btooling\b/i] },
|
||||||
|
{ tag: 'development-logs', patterns: [/\bdevelopment logs\b/i, /\bdev logs\b/i, /\blog\b/i] },
|
||||||
|
{ tag: 'embedded-devices', patterns: [/\bembedded devices\b/i, /\bembedded\b/i] },
|
||||||
|
{ tag: 'hardware', patterns: [/\bhardware\b/i, /\baudio gear\b/i] },
|
||||||
|
{ tag: 'ui-frameworks', patterns: [/\bui frameworks\b/i, /\bui framework\b/i] },
|
||||||
|
{ tag: 'modding', patterns: [/\bmodding\b/i, /\bmodding workflows\b/i] },
|
||||||
|
{ tag: 'prototypes', patterns: [/\bprototypes\b/i, /\bprototype\b/i] },
|
||||||
|
{ tag: 'experiments', patterns: [/\bexperiments\b/i, /\bexperiment\b/i] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FALLBACK_TAG_STOP_WORDS = new Set([
|
||||||
|
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'blog', 'both', 'but', 'by', 'for',
|
||||||
|
'from', 'here', 'into', 'its', 'just', 'make', 'more', 'onto', 'post', 'that',
|
||||||
|
'the', 'their', 'this', 'those', 'through', 'use', 'useful', 'using', 'welcome',
|
||||||
|
'with', 'worth', 'you', 'your',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ title: string, body: string, tags: string }} payload
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
function buildFallbackTags(payload) {
|
||||||
|
const sourceText = `${payload.title || ''}\n${payload.body || ''}`;
|
||||||
|
const fallback = normalizeGeneratedTags(parseTagArray(payload.tags || ''));
|
||||||
|
const seen = new Set(fallback);
|
||||||
|
|
||||||
|
for (const rule of FALLBACK_TAG_RULES) {
|
||||||
|
if (seen.has(rule.tag)) continue;
|
||||||
|
if (rule.patterns.some(pattern => pattern.test(sourceText))) {
|
||||||
|
fallback.push(rule.tag);
|
||||||
|
seen.add(rule.tag);
|
||||||
|
}
|
||||||
|
if (fallback.length >= 8) return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleTokens = (payload.title || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.match(/[a-z0-9]+/g) || [];
|
||||||
|
for (const token of titleTokens) {
|
||||||
|
if (token.length < 4 || FALLBACK_TAG_STOP_WORDS.has(token) || seen.has(token)) continue;
|
||||||
|
fallback.push(token);
|
||||||
|
seen.add(token);
|
||||||
|
if (fallback.length >= 8) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeGeneratedTags(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} message
|
||||||
|
*/
|
||||||
|
function log(message) {
|
||||||
|
const ts = new Date().toISOString();
|
||||||
|
output?.appendLine(`[${ts}] ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string | undefined} urlOrBase
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function resolveLmStudioBaseUrl(urlOrBase) {
|
||||||
|
const raw = (urlOrBase || '').trim();
|
||||||
|
if (!raw) return DEFAULT_LM_STUDIO_BASE_URL;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(raw);
|
||||||
|
parsed.search = '';
|
||||||
|
parsed.hash = '';
|
||||||
|
const pathname = parsed.pathname || '/';
|
||||||
|
const cut = pathname.search(/\/(api\/v1|v1)\b/i);
|
||||||
|
parsed.pathname = cut >= 0 ? pathname.slice(0, cut) || '/' : pathname;
|
||||||
|
return parsed.toString().replace(/\/$/, '');
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_LM_STUDIO_BASE_URL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} baseUrl
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
function buildChatEndpointCandidates(baseUrl) {
|
||||||
|
return LM_STUDIO_CHAT_ENDPOINTS.map(p => `${baseUrl}${p}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} data
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function extractLmStudioMessageContent(data) {
|
||||||
|
if (!data || typeof data !== 'object') return '';
|
||||||
|
const obj = /** @type {Record<string, unknown>} */ (data);
|
||||||
|
|
||||||
|
const choices = Array.isArray(obj.choices) ? obj.choices : [];
|
||||||
|
for (const choice of choices) {
|
||||||
|
if (!choice || typeof choice !== 'object') continue;
|
||||||
|
const c = /** @type {Record<string, unknown>} */ (choice);
|
||||||
|
const message = c.message;
|
||||||
|
if (message && typeof message === 'object') {
|
||||||
|
const messageObj = /** @type {Record<string, unknown>} */ (message);
|
||||||
|
const content = messageObj.content;
|
||||||
|
if (typeof content === 'string' && content.trim()) return content;
|
||||||
|
const reasoningContent = messageObj.reasoning_content;
|
||||||
|
if (typeof reasoningContent === 'string' && reasoningContent.trim()) return reasoningContent;
|
||||||
|
}
|
||||||
|
if (typeof c.text === 'string' && c.text.trim()) return c.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof obj.output_text === 'string' && obj.output_text.trim()) return obj.output_text;
|
||||||
|
if (typeof obj.content === 'string' && obj.content.trim()) return obj.content;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} baseUrl
|
||||||
|
* @param {AbortSignal} signal
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
async function detectLmStudioModel(baseUrl, signal) {
|
||||||
|
for (const endpointPath of LM_STUDIO_MODELS_ENDPOINTS) {
|
||||||
|
const endpoint = `${baseUrl}${endpointPath}`;
|
||||||
|
try {
|
||||||
|
log(`AI tags: probing models endpoint ${endpoint}`);
|
||||||
|
const res = await fetch(endpoint, { method: 'GET', signal });
|
||||||
|
log(`AI tags: models endpoint status ${res.status} (${endpoint})`);
|
||||||
|
if (!res.ok) continue;
|
||||||
|
/** @type {{ data?: Array<{ id?: string, state?: string, loaded?: boolean }> }} */
|
||||||
|
const data = await res.json();
|
||||||
|
const models = Array.isArray(data?.data) ? data.data : [];
|
||||||
|
const loaded = models.find(m => m && typeof m.id === 'string' && (m.loaded === true || String(m.state || '').toLowerCase() === 'loaded'));
|
||||||
|
if (loaded?.id) {
|
||||||
|
log(`AI tags: selected loaded model ${loaded.id}`);
|
||||||
|
return loaded.id;
|
||||||
|
}
|
||||||
|
const first = models.find(m => m && typeof m.id === 'string');
|
||||||
|
if (first?.id) {
|
||||||
|
log(`AI tags: selected first model ${first.id}`);
|
||||||
|
return first.id;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Try next endpoint variant.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log('AI tags: could not detect model from API, using fallback local-model');
|
||||||
|
return 'local-model';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} endpoint
|
||||||
|
* @param {Record<string, unknown>} body
|
||||||
|
* @param {AbortSignal} signal
|
||||||
|
* @returns {Promise<Response>}
|
||||||
|
*/
|
||||||
|
async function postLmStudio(endpoint, body, signal) {
|
||||||
|
const payload = { ...body, stream: false };
|
||||||
|
return fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
signal,
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates tags from LM Studio OpenAI-compatible endpoint.
|
||||||
|
* @param {{ title: string, body: string, tags: string }} payload
|
||||||
|
* @returns {Promise<string[]>}
|
||||||
|
*/
|
||||||
|
async function generateTagsWithLmStudio(payload) {
|
||||||
|
const configuredUrl = process.env.BLOG_EDITOR_LM_STUDIO_URL;
|
||||||
|
const baseUrl = resolveLmStudioBaseUrl(configuredUrl);
|
||||||
|
const endpoints = buildChatEndpointCandidates(baseUrl);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 90000);
|
||||||
|
|
||||||
|
const prompt = [
|
||||||
|
'Generate 3 to 8 relevant blog tags.',
|
||||||
|
'Return at least 1 tag even if the post is sparse.',
|
||||||
|
'Keep any existing tag if it is still relevant.',
|
||||||
|
'Prefer concrete topical tags over generic filler.',
|
||||||
|
`Title: ${payload.title || '(untitled)'}`,
|
||||||
|
`Existing tags: ${payload.tags || '(none)'}`,
|
||||||
|
'Body:',
|
||||||
|
(payload.body || '').slice(0, 6000),
|
||||||
|
].join('\n\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
log(`AI tags: request started (version=${EXTENSION_VERSION}, title=${payload.title ? 'yes' : 'no'}, bodyChars=${payload.body.length}, existingTags=${payload.tags ? 'yes' : 'no'})`);
|
||||||
|
log(`AI tags: base URL ${baseUrl}`);
|
||||||
|
log(`AI tags: title preview ${JSON.stringify((payload.title || '').slice(0, 120))}`);
|
||||||
|
log(`AI tags: existing tags preview ${JSON.stringify((payload.tags || '').slice(0, 120))}`);
|
||||||
|
log(`AI tags: body preview ${JSON.stringify((payload.body || '').slice(0, 280))}`);
|
||||||
|
const modelId = await detectLmStudioModel(baseUrl, controller.signal);
|
||||||
|
log(`AI tags: using model ${modelId}`);
|
||||||
|
let lastError = '';
|
||||||
|
|
||||||
|
for (const endpoint of endpoints) {
|
||||||
|
const requestBody = {
|
||||||
|
model: modelId,
|
||||||
|
temperature: 0.2,
|
||||||
|
max_tokens: 256,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: 'Return only a JSON array that matches the provided schema. Do not include prose, markdown, commentary, or reasoning. The array must contain at least one relevant tag.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: prompt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
response_format: {
|
||||||
|
type: 'json_schema',
|
||||||
|
json_schema: {
|
||||||
|
name: 'blog_tags',
|
||||||
|
strict: true,
|
||||||
|
schema: BLOG_TAGS_JSON_SCHEMA,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let res = await postLmStudio(endpoint, requestBody, controller.signal);
|
||||||
|
log(`AI tags: chat attempt ${endpoint} -> ${res.status}`);
|
||||||
|
let textOnError = '';
|
||||||
|
if (!res.ok) textOnError = await res.text();
|
||||||
|
|
||||||
|
// Structured output is required here. If an endpoint does not support
|
||||||
|
// response_format/json_schema, skip it and try the next endpoint
|
||||||
|
// instead of falling back to unstructured text generation.
|
||||||
|
if (!res.ok && (res.status === 400 || res.status === 422)) {
|
||||||
|
const unsupported = /response_format|json_schema|unsupported|unknown/i.test(textOnError);
|
||||||
|
if (unsupported) {
|
||||||
|
lastError = `Endpoint ${endpoint} does not support structured output.`;
|
||||||
|
log(`AI tags: ${lastError}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = textOnError || await res.text();
|
||||||
|
const maybeWrongEndpoint = res.status === 404 || res.status === 405 || /not\s*found/i.test(text);
|
||||||
|
if (maybeWrongEndpoint) {
|
||||||
|
lastError = `Endpoint ${endpoint} unavailable (${res.status}).`;
|
||||||
|
log(`AI tags: ${lastError}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lastError = `LM Studio request failed (${res.status}) on ${endpoint}: ${text || 'no body'}`;
|
||||||
|
log(`AI tags: ${lastError}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {unknown} */
|
||||||
|
const data = await res.json();
|
||||||
|
const content = extractLmStudioMessageContent(data);
|
||||||
|
const parsed = parseTagArray(content);
|
||||||
|
const tags = normalizeGeneratedTags(parsed);
|
||||||
|
if (tags.length === 0) {
|
||||||
|
const fallbackTags = buildFallbackTags(payload);
|
||||||
|
if (fallbackTags.length > 0) {
|
||||||
|
log(`AI tags: LM Studio returned empty structured output, falling back to ${fallbackTags.length} derived tag(s)`);
|
||||||
|
log(`AI tags: fallback tags ${JSON.stringify(fallbackTags)}`);
|
||||||
|
return fallbackTags;
|
||||||
|
}
|
||||||
|
lastError = `LM Studio returned no usable tags from ${endpoint}.`;
|
||||||
|
log(`AI tags: ${lastError}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
log(`AI tags: success via ${endpoint}, generated ${tags.length} tags`);
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(lastError || `No LM Studio chat endpoint succeeded. Tried: ${endpoints.join(', ')}`);
|
||||||
|
} catch (err) {
|
||||||
|
if (err && typeof err === 'object' && 'name' in err && err.name === 'AbortError') {
|
||||||
|
log('AI tags: request timed out after 90s');
|
||||||
|
throw new Error('LM Studio request timed out after 90s.');
|
||||||
|
}
|
||||||
|
log(`AI tags: exception ${String(err)}`);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
log('AI tags: request finished');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tree item representing a single blog post.
|
||||||
|
* Stores the slug separately so context-menu commands can retrieve it
|
||||||
|
* even when the label displays the human-readable title.
|
||||||
|
*/
|
||||||
|
class BlogPostItem extends vscode.TreeItem {
|
||||||
|
/**
|
||||||
|
* @param {string} slug
|
||||||
|
* @param {string} title
|
||||||
|
* @param {string} date
|
||||||
|
*/
|
||||||
|
constructor(slug, title, date) {
|
||||||
|
super(title || slug, vscode.TreeItemCollapsibleState.None);
|
||||||
|
this.id = slug;
|
||||||
|
this.slug = slug;
|
||||||
|
this.description = date || '';
|
||||||
|
this.tooltip = `${slug}.md`;
|
||||||
|
this.contextValue = 'blogPost';
|
||||||
|
this.command = { command: 'blogEditor.openPost', title: 'Open Post', arguments: [slug] };
|
||||||
|
this.iconPath = new vscode.ThemeIcon('file');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks up from startDir to find node_modules containing a package.
|
||||||
|
* @param {string} pkg
|
||||||
|
* @param {string} startDir
|
||||||
|
* @returns {string | null}
|
||||||
|
*/
|
||||||
|
function resolveFromNearestNodeModules(pkg, startDir) {
|
||||||
|
let dir = startDir;
|
||||||
|
while (true) {
|
||||||
|
const candidate = path.join(dir, 'node_modules', pkg);
|
||||||
|
if (fs.existsSync(candidate)) return candidate;
|
||||||
|
const parent = path.dirname(dir);
|
||||||
|
if (parent === dir) return null;
|
||||||
|
dir = parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {vscode.ExtensionContext} context
|
||||||
|
*/
|
||||||
|
async function activate(context) {
|
||||||
|
output = vscode.window.createOutputChannel('Blog Editor');
|
||||||
|
context.subscriptions.push(output);
|
||||||
|
log(`Blog Editor activated (version ${EXTENSION_VERSION})`);
|
||||||
|
|
||||||
|
sidebarProvider = new BlogSidebarProvider();
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.window.registerTreeDataProvider('blog-editor-sidebar', sidebarProvider),
|
||||||
|
vscode.commands.registerCommand('blogEditor.open', () => openEditor(context)),
|
||||||
|
vscode.commands.registerCommand('blogEditor.openPost', (slug) => openEditor(context, slug)),
|
||||||
|
vscode.commands.registerCommand('blogEditor.refreshPosts', () => sidebarProvider?.refresh()),
|
||||||
|
vscode.commands.registerCommand('blogEditor.newPost', () => newPost(context)),
|
||||||
|
vscode.commands.registerCommand('blogEditor.renamePost', (item) => renamePost(item))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompts the user for a slug and creates a new blank blog post.
|
||||||
|
* @param {vscode.ExtensionContext} context
|
||||||
|
*/
|
||||||
|
async function newPost(context) {
|
||||||
|
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||||
|
if (!workspaceRoot) { vscode.window.showErrorMessage('No workspace folder open.'); return; }
|
||||||
|
|
||||||
|
const blogDir = path.join(workspaceRoot, 'website', 'data', 'blog');
|
||||||
|
|
||||||
|
const slug = await vscode.window.showInputBox({
|
||||||
|
prompt: 'Enter a slug for the new post (lowercase letters, numbers, hyphens)',
|
||||||
|
placeHolder: 'my-new-post',
|
||||||
|
validateInput: v => isValidSlug(v) ? null : 'Use only lowercase letters, numbers, and hyphens (e.g. my-post)'
|
||||||
|
});
|
||||||
|
if (!slug) return;
|
||||||
|
|
||||||
|
const filePath = path.join(blogDir, `${slug}.md`);
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
vscode.window.showErrorMessage(`A post named "${slug}" already exists.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString().slice(0, 10);
|
||||||
|
const author = getPreviousAuthor(blogDir);
|
||||||
|
const authorLine = author ? `\nauthor: "${author.replace(/"/g, '\\"')}"` : '';
|
||||||
|
const starter = `---\ntitle: "${slug}"\ndate: "${now}"${authorLine}\ntags: ""\n---\n\n`;
|
||||||
|
fs.mkdirSync(blogDir, { recursive: true });
|
||||||
|
fs.writeFileSync(filePath, starter, 'utf-8');
|
||||||
|
activeBlogDir = blogDir;
|
||||||
|
sidebarProvider?.refresh();
|
||||||
|
openEditor(context, slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renames a blog post slug via right-click context menu on a sidebar tree item.
|
||||||
|
* @param {vscode.TreeItem} item
|
||||||
|
*/
|
||||||
|
async function renamePost(item) {
|
||||||
|
const oldSlug = item?.slug ?? item?.id ?? (typeof item?.label === 'string' ? item.label : undefined);
|
||||||
|
if (!oldSlug || typeof oldSlug !== 'string') { vscode.window.showErrorMessage('Could not determine post slug.'); return; }
|
||||||
|
|
||||||
|
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||||
|
if (!workspaceRoot) { vscode.window.showErrorMessage('No workspace folder open.'); return; }
|
||||||
|
|
||||||
|
const blogDir = path.join(workspaceRoot, 'website', 'data', 'blog');
|
||||||
|
const oldPath = path.join(blogDir, `${oldSlug}.md`);
|
||||||
|
|
||||||
|
const newSlug = await vscode.window.showInputBox({
|
||||||
|
prompt: `Rename "${oldSlug}" to:`,
|
||||||
|
value: oldSlug,
|
||||||
|
validateInput: v => isValidSlug(v) ? null : 'Use only lowercase letters, numbers, and hyphens'
|
||||||
|
});
|
||||||
|
if (!newSlug || newSlug === oldSlug) return;
|
||||||
|
|
||||||
|
const newPath = path.join(blogDir, `${newSlug}.md`);
|
||||||
|
if (fs.existsSync(newPath)) { vscode.window.showErrorMessage(`"${newSlug}" already exists.`); return; }
|
||||||
|
|
||||||
|
fs.renameSync(oldPath, newPath);
|
||||||
|
sidebarProvider?.refresh();
|
||||||
|
|
||||||
|
// If this post is currently open in the editor, refresh it with the new slug.
|
||||||
|
if (panel) {
|
||||||
|
panel.webview.postMessage({ type: 'openSlug', slug: newSlug });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the Milkdown JS + CSS bundles into dist/ if they don't exist yet.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function ensureBundle() {
|
||||||
|
if (!fs.existsSync(DIST_DIR)) fs.mkdirSync(DIST_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const milkdownPath = path.join(DIST_DIR, 'milkdown.js');
|
||||||
|
const commonCssPath = path.join(DIST_DIR, 'milkdown-common.css');
|
||||||
|
const themeCssPath = path.join(DIST_DIR, 'milkdown-theme.css');
|
||||||
|
const spellcheckPath = path.join(DIST_DIR, 'spellcheck.js');
|
||||||
|
|
||||||
|
// Prefer prebuilt assets in packaged VSIX to avoid requiring esbuild at runtime.
|
||||||
|
if (fs.existsSync(milkdownPath) && fs.existsSync(commonCssPath) && fs.existsSync(themeCssPath) && fs.existsSync(spellcheckPath)) return;
|
||||||
|
|
||||||
|
await vscode.window.withProgress(
|
||||||
|
{ location: vscode.ProgressLocation.Notification, title: 'Blog Editor: Bundling Milkdown…' },
|
||||||
|
async () => {
|
||||||
|
const esbuildPkg = resolveFromNearestNodeModules('esbuild', EXT_DIR)
|
||||||
|
?? resolveFromNearestNodeModules('esbuild', process.cwd());
|
||||||
|
if (!esbuildPkg) {
|
||||||
|
throw new Error('Missing prebuilt dist assets and esbuild is unavailable. Rebuild the VSIX from the repo root so dist files are packaged.');
|
||||||
|
}
|
||||||
|
const esbuild = require(path.join(esbuildPkg, 'lib', 'main.js'));
|
||||||
|
const crepePkg = resolveFromNearestNodeModules('@milkdown/crepe', EXT_DIR)
|
||||||
|
?? resolveFromNearestNodeModules('@milkdown/crepe', process.cwd());
|
||||||
|
if (!crepePkg) {
|
||||||
|
throw new Error('Cannot find @milkdown/crepe for bundling. Install workspace dependencies or rebuild the VSIX with prebuilt dist.');
|
||||||
|
}
|
||||||
|
const rootDir = path.dirname(path.dirname(esbuildPkg));
|
||||||
|
const crepeDir = path.join(crepePkg, 'lib', 'theme');
|
||||||
|
const fontLoaders = { '.woff2': 'dataurl', '.woff': 'dataurl', '.ttf': 'dataurl', '.eot': 'dataurl', '.svg': 'dataurl' };
|
||||||
|
|
||||||
|
const [jsResult, cssCommon, cssTheme, spellcheckResult] = await Promise.all([
|
||||||
|
esbuild.build({
|
||||||
|
stdin: { contents: `export { Crepe, CrepeFeature } from '@milkdown/crepe';`, resolveDir: rootDir, loader: 'js' },
|
||||||
|
bundle: true, format: 'esm', platform: 'browser', target: 'es2022', minify: true, write: false,
|
||||||
|
}),
|
||||||
|
esbuild.build({
|
||||||
|
entryPoints: [path.join(crepeDir, 'common', 'style.css')],
|
||||||
|
bundle: true, write: false, loader: fontLoaders,
|
||||||
|
}),
|
||||||
|
esbuild.build({
|
||||||
|
entryPoints: [path.join(crepeDir, 'frame-dark', 'style.css')],
|
||||||
|
bundle: true, write: false, loader: fontLoaders,
|
||||||
|
}),
|
||||||
|
esbuild.build({
|
||||||
|
entryPoints: [path.join(EXT_DIR, 'spellcheck.js')],
|
||||||
|
bundle: true,
|
||||||
|
format: 'esm',
|
||||||
|
platform: 'browser',
|
||||||
|
target: 'es2022',
|
||||||
|
minify: true,
|
||||||
|
write: false,
|
||||||
|
loader: { '.aff': 'text', '.dic': 'text' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(DIST_DIR, 'milkdown.js'), jsResult.outputFiles[0].contents);
|
||||||
|
fs.writeFileSync(path.join(DIST_DIR, 'milkdown-common.css'), cssCommon.outputFiles[0].contents);
|
||||||
|
fs.writeFileSync(path.join(DIST_DIR, 'milkdown-theme.css'), cssTheme.outputFiles[0].contents);
|
||||||
|
fs.writeFileSync(path.join(DIST_DIR, 'spellcheck.js'), spellcheckResult.outputFiles[0].contents);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {vscode.ExtensionContext} context
|
||||||
|
* @param {string} [initialSlug]
|
||||||
|
*/
|
||||||
|
async function openEditor(context, initialSlug) {
|
||||||
|
if (panel) {
|
||||||
|
panel.reveal();
|
||||||
|
if (initialSlug) {
|
||||||
|
panel.webview.postMessage({ type: 'openSlug', slug: initialSlug });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||||
|
if (!workspaceRoot) { vscode.window.showErrorMessage('No workspace folder open.'); return; }
|
||||||
|
|
||||||
|
await ensureBundle();
|
||||||
|
|
||||||
|
const websiteDir = path.join(workspaceRoot, 'website');
|
||||||
|
const blogDir = path.join(websiteDir, 'data', 'blog');
|
||||||
|
activeBlogDir = blogDir;
|
||||||
|
sidebarProvider?.refresh();
|
||||||
|
|
||||||
|
if (initialSlug) pendingOpenSlug = initialSlug;
|
||||||
|
|
||||||
|
panel = vscode.window.createWebviewPanel(
|
||||||
|
'blogEditor', 'Blog Editor',
|
||||||
|
vscode.ViewColumn.One,
|
||||||
|
{
|
||||||
|
enableScripts: true,
|
||||||
|
retainContextWhenHidden: true,
|
||||||
|
localResourceRoots: [
|
||||||
|
vscode.Uri.file(DIST_DIR),
|
||||||
|
vscode.Uri.file(websiteDir),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
panel.webview.html = buildHtml(panel.webview);
|
||||||
|
|
||||||
|
panel.webview.onDidReceiveMessage(msg => handleMessage(msg, blogDir, websiteDir), undefined, context.subscriptions);
|
||||||
|
panel.onDidDispose(() => {
|
||||||
|
panel = undefined;
|
||||||
|
pendingOpenSlug = undefined;
|
||||||
|
}, undefined, context.subscriptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the webview HTML, replacing asset URIs and nonce.
|
||||||
|
* @param {vscode.Webview} webview
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function buildHtml(webview) {
|
||||||
|
const nonce = crypto.randomBytes(16).toString('hex');
|
||||||
|
const milkdownJs = webview.asWebviewUri(vscode.Uri.file(path.join(DIST_DIR, 'milkdown.js')));
|
||||||
|
const spellcheckJs = webview.asWebviewUri(vscode.Uri.file(path.join(DIST_DIR, 'spellcheck.js')));
|
||||||
|
const commonCss = webview.asWebviewUri(vscode.Uri.file(path.join(DIST_DIR, 'milkdown-common.css')));
|
||||||
|
const themeCss = webview.asWebviewUri(vscode.Uri.file(path.join(DIST_DIR, 'milkdown-theme.css')));
|
||||||
|
const csp = webview.cspSource;
|
||||||
|
|
||||||
|
const template = fs.readFileSync(path.join(EXT_DIR, 'editor.html'), 'utf-8');
|
||||||
|
return template
|
||||||
|
.replace(/\{\{NONCE\}\}/g, nonce)
|
||||||
|
.replace(/\{\{CSP_SOURCE\}\}/g, csp)
|
||||||
|
.replace('{{MILKDOWN_JS}}', milkdownJs.toString())
|
||||||
|
.replace('{{SPELLCHECK_JS}}', spellcheckJs.toString())
|
||||||
|
.replace('{{COMMON_CSS}}', commonCss.toString())
|
||||||
|
.replace('{{THEME_CSS}}', themeCss.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits markdown image target into URL/path and optional trailing title segment.
|
||||||
|
* Examples:
|
||||||
|
* data/blog/media/a.png "Caption" -> { src: data/blog/media/a.png, suffix: "Caption" }
|
||||||
|
* https://x/y.png -> { src: https://x/y.png, suffix: '' }
|
||||||
|
* @param {string} target
|
||||||
|
* @returns {{ src: string, suffix: string }}
|
||||||
|
*/
|
||||||
|
function splitImageTarget(target) {
|
||||||
|
const trimmed = target.trim();
|
||||||
|
const m = trimmed.match(/^(\S+)(\s+["'][\s\S]*["'])?$/);
|
||||||
|
if (!m) return { src: trimmed, suffix: '' };
|
||||||
|
return { src: m[1], suffix: m[2] ?? '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces relative image paths in markdown with webview-safe URIs.
|
||||||
|
* @param {string} content
|
||||||
|
* @param {string} websiteBaseUri Result of webview.asWebviewUri(websiteDir).toString()
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function injectWebviewImageUris(content, websiteBaseUri) {
|
||||||
|
return content.replace(/(\!\[[^\]]*\]\()([^)]+)(\))/g, (match, open, target, close) => {
|
||||||
|
const { src, suffix } = splitImageTarget(target);
|
||||||
|
if (/^https?:|^data:|^blob:/.test(src)) return match;
|
||||||
|
return `${open}${websiteBaseUri}/${src.replace(/^\//, '')}${suffix}${close}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverts webview URIs in markdown back to relative paths before saving.
|
||||||
|
* @param {string} content
|
||||||
|
* @param {string} websiteBaseUri
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function revertWebviewImageUris(content, websiteBaseUri) {
|
||||||
|
const escaped = websiteBaseUri.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
return content.replace(
|
||||||
|
new RegExp(`(\\!\\[[^\\]]*\\]\\()${escaped}/([^)]+)(\\))`, 'g'),
|
||||||
|
(_m, open, target, close) => {
|
||||||
|
const { src, suffix } = splitImageTarget(target);
|
||||||
|
return `${open}${src}${suffix}${close}`;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles messages from the webview.
|
||||||
|
* @param {{ type: string, slug?: string, oldSlug?: string, newSlug?: string, content?: string, filename?: string, dataBase64?: string, blobUrl?: string, title?: string, body?: string, tags?: string }} msg
|
||||||
|
* @param {string} blogDir
|
||||||
|
* @param {string} websiteDir
|
||||||
|
*/
|
||||||
|
function handleMessage(msg, blogDir, websiteDir) {
|
||||||
|
const webview = panel?.webview;
|
||||||
|
if (!webview) return;
|
||||||
|
try {
|
||||||
|
const websiteBaseUri = webview.asWebviewUri(vscode.Uri.file(websiteDir)).toString();
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'getPosts': {
|
||||||
|
const slugs = fs.readdirSync(blogDir)
|
||||||
|
.filter(f => f.endsWith('.md') && !f.startsWith('.'))
|
||||||
|
.map(f => path.basename(f, '.md'))
|
||||||
|
.sort();
|
||||||
|
webview.postMessage({ type: 'posts', slugs });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'getPost': {
|
||||||
|
const filePath = safePostPath(blogDir, msg.slug);
|
||||||
|
if (!filePath) return;
|
||||||
|
const raw = fs.readFileSync(filePath, 'utf-8').replace(/!\[[^\]]*\]\(blob:[^)]+\)\r?\n?/g, '');
|
||||||
|
// Build imageMap: webviewUri → relativePath, so the browser can reverse blob URLs
|
||||||
|
const imageMap = {};
|
||||||
|
const imgPattern = /!\[[^\]]*\]\(([^)]+)\)/g;
|
||||||
|
let imgMatch;
|
||||||
|
while ((imgMatch = imgPattern.exec(raw)) !== null) {
|
||||||
|
const { src } = splitImageTarget(imgMatch[1]);
|
||||||
|
if (/^https?:|^data:|^blob:/.test(src)) continue;
|
||||||
|
const absPath = path.join(websiteDir, src.replace(/^\//, ''));
|
||||||
|
if (fs.existsSync(absPath)) {
|
||||||
|
const uri = webview.asWebviewUri(vscode.Uri.file(absPath)).toString();
|
||||||
|
imageMap[uri] = src;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const content = injectWebviewImageUris(raw, websiteBaseUri);
|
||||||
|
webview.postMessage({ type: 'post', slug: msg.slug, content, imageMap });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'savePost': {
|
||||||
|
const filePath = safePostPath(blogDir, msg.slug);
|
||||||
|
if (!filePath) return;
|
||||||
|
const content = revertWebviewImageUris(msg.content, websiteBaseUri);
|
||||||
|
fs.writeFileSync(filePath, content, 'utf-8');
|
||||||
|
webview.postMessage({ type: 'saved', slug: msg.slug });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'saveMedia': {
|
||||||
|
const mediaDir = path.join(websiteDir, 'data', 'blog', 'media');
|
||||||
|
fs.mkdirSync(mediaDir, { recursive: true });
|
||||||
|
const safeName = (msg.filename || 'paste.png').replace(/[^a-z0-9._-]/gi, '_');
|
||||||
|
const filePath = path.join(mediaDir, safeName);
|
||||||
|
fs.writeFileSync(filePath, Buffer.from(msg.dataBase64, 'base64'));
|
||||||
|
const relativePath = `data/blog/media/${safeName}`;
|
||||||
|
const savedUri = webview.asWebviewUri(vscode.Uri.file(filePath)).toString();
|
||||||
|
webview.postMessage({ type: 'mediaSaved', blobUrl: msg.blobUrl, relativePath, webviewUri: savedUri });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'newPost': {
|
||||||
|
if (!isValidSlug(msg.slug)) {
|
||||||
|
webview.postMessage({ type: 'error', message: 'Invalid slug.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const filePath = path.join(blogDir, `${msg.slug}.md`);
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
webview.postMessage({ type: 'error', message: `"${msg.slug}" already exists.` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fs.writeFileSync(filePath, msg.content, 'utf-8');
|
||||||
|
webview.postMessage({ type: 'created', slug: msg.slug });
|
||||||
|
sidebarProvider?.refresh();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'renamePost': {
|
||||||
|
if (!isValidSlug(msg.newSlug)) {
|
||||||
|
webview.postMessage({ type: 'error', message: 'Invalid slug.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const oldFilePath = safePostPath(blogDir, msg.oldSlug);
|
||||||
|
if (!oldFilePath) return;
|
||||||
|
const newFilePath = path.join(blogDir, `${msg.newSlug}.md`);
|
||||||
|
if (!newFilePath.startsWith(blogDir + path.sep)) return;
|
||||||
|
if (fs.existsSync(newFilePath)) {
|
||||||
|
webview.postMessage({ type: 'error', message: `"${msg.newSlug}" already exists.` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fs.renameSync(oldFilePath, newFilePath);
|
||||||
|
sidebarProvider?.refresh();
|
||||||
|
webview.postMessage({ type: 'renamed', oldSlug: msg.oldSlug, newSlug: msg.newSlug });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'refreshPosts': {
|
||||||
|
sidebarProvider?.refresh();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'generateTags': {
|
||||||
|
const title = (msg.title || '').trim();
|
||||||
|
const body = (msg.body || '').trim();
|
||||||
|
const tags = (msg.tags || '').trim();
|
||||||
|
log('AI tags: webview requested generation');
|
||||||
|
if (!title && !body) {
|
||||||
|
log('AI tags: rejected request - missing title/body');
|
||||||
|
webview.postMessage({ type: 'error', message: 'Need title or body for tag generation.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
webview.postMessage({ type: 'aiTagsStarted' });
|
||||||
|
generateTagsWithLmStudio({ title, body, tags })
|
||||||
|
.then(generated => {
|
||||||
|
log('AI tags: sending generated tags to webview');
|
||||||
|
webview.postMessage({ type: 'aiTagsGenerated', tags: generated });
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
log(`AI tags: generation failed ${String(err)}`);
|
||||||
|
webview.postMessage({ type: 'error', message: `AI tag generation failed: ${String(err)}` });
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ready': {
|
||||||
|
if (pendingOpenSlug) {
|
||||||
|
webview.postMessage({ type: 'openSlug', slug: pendingOpenSlug });
|
||||||
|
pendingOpenSlug = undefined;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
webview.postMessage({ type: 'error', message: String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an absolute path to a post file, guarding against path traversal.
|
||||||
|
* @param {string} blogDir
|
||||||
|
* @param {string | undefined} slug
|
||||||
|
* @returns {string | null}
|
||||||
|
*/
|
||||||
|
function safePostPath(blogDir, slug) {
|
||||||
|
if (!isValidSlug(slug)) {
|
||||||
|
panel?.webview.postMessage({ type: 'error', message: 'Invalid slug.' });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const resolved = path.resolve(blogDir, `${slug}.md`);
|
||||||
|
if (!resolved.startsWith(blogDir + path.sep)) return null;
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string | undefined} slug
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isValidSlug(slug) {
|
||||||
|
return typeof slug === 'string' && /^[a-z0-9][a-z0-9-]*$/.test(slug) && slug.length <= 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deactivate() {}
|
||||||
|
|
||||||
|
module.exports = { activate, deactivate };
|
||||||
13
tools/blogeditor/launch.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Blog Editor Extension",
|
||||||
|
"type": "extensionHost",
|
||||||
|
"request": "launch",
|
||||||
|
"args": [
|
||||||
|
"--extensionDevelopmentPath=${workspaceFolder}/.vscode"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
8
tools/blogeditor/media/blog-editor.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" fill="none">
|
||||||
|
<rect x="8" y="6" width="48" height="52" rx="8" fill="#1f2937" stroke="#94a3b8" stroke-width="3"/>
|
||||||
|
<rect x="16" y="16" width="32" height="4" rx="2" fill="#38bdf8"/>
|
||||||
|
<rect x="16" y="26" width="24" height="4" rx="2" fill="#cbd5e1"/>
|
||||||
|
<rect x="16" y="36" width="20" height="4" rx="2" fill="#cbd5e1"/>
|
||||||
|
<path d="M42 42L52 52" stroke="#f59e0b" stroke-width="5" stroke-linecap="round"/>
|
||||||
|
<circle cx="38" cy="38" r="8" stroke="#f59e0b" stroke-width="4"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 560 B |
15
tools/blogeditor/package-lock.json
generated
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "blog-editor",
|
||||||
|
"version": "0.1.3",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "blog-editor",
|
||||||
|
"version": "0.1.3",
|
||||||
|
"engines": {
|
||||||
|
"vscode": "^1.80.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
93
tools/blogeditor/package.json
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
{
|
||||||
|
"name": "blog-editor",
|
||||||
|
"displayName": "Blog Editor",
|
||||||
|
"description": "WYSIWYG Milkdown blog post editor for this workspace",
|
||||||
|
"version": "0.1.32",
|
||||||
|
"publisher": "local",
|
||||||
|
"engines": {
|
||||||
|
"vscode": "^1.80.0"
|
||||||
|
},
|
||||||
|
"categories": [
|
||||||
|
"Other"
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/local/blog-editor"
|
||||||
|
},
|
||||||
|
"activationEvents": [],
|
||||||
|
"main": "./extension.js",
|
||||||
|
"contributes": {
|
||||||
|
"commands": [
|
||||||
|
{
|
||||||
|
"command": "blogEditor.open",
|
||||||
|
"title": "Open Blog Editor",
|
||||||
|
"category": "Blog"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "blogEditor.openPost",
|
||||||
|
"title": "Open Blog Post",
|
||||||
|
"category": "Blog"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "blogEditor.refreshPosts",
|
||||||
|
"title": "Refresh Blog Posts",
|
||||||
|
"category": "Blog",
|
||||||
|
"icon": "$(refresh)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "blogEditor.newPost",
|
||||||
|
"title": "New Post",
|
||||||
|
"category": "Blog",
|
||||||
|
"icon": "$(add)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "blogEditor.renamePost",
|
||||||
|
"title": "Rename Post",
|
||||||
|
"category": "Blog"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"viewsContainers": {
|
||||||
|
"activitybar": [
|
||||||
|
{
|
||||||
|
"id": "blog-editor-container",
|
||||||
|
"title": "Blog Editor",
|
||||||
|
"icon": "media/blog-editor.svg"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"views": {
|
||||||
|
"blog-editor-container": [
|
||||||
|
{
|
||||||
|
"id": "blog-editor-sidebar",
|
||||||
|
"name": "Posts"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"menus": {
|
||||||
|
"view/title": [
|
||||||
|
{
|
||||||
|
"command": "blogEditor.newPost",
|
||||||
|
"when": "view == blog-editor-sidebar",
|
||||||
|
"group": "navigation@1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "blogEditor.refreshPosts",
|
||||||
|
"when": "view == blog-editor-sidebar",
|
||||||
|
"group": "navigation@2"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"view/item/context": [
|
||||||
|
{
|
||||||
|
"command": "blogEditor.renamePost",
|
||||||
|
"when": "view == blog-editor-sidebar && viewItem == blogPost",
|
||||||
|
"group": "inline"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"prepackage": "node ./bump-version.js",
|
||||||
|
"package": "node ./build-dist.js",
|
||||||
|
"postpackage": "npx @vscode/vsce package --out blog-editor.vsix --no-dependencies --allow-missing-repository"
|
||||||
|
}
|
||||||
|
}
|
||||||
273
tools/blogeditor/spellcheck.js
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
import nspell from 'nspell';
|
||||||
|
import affData from 'dictionary-en-us/index.aff';
|
||||||
|
import dicData from 'dictionary-en-us/index.dic';
|
||||||
|
|
||||||
|
const spell = nspell(affData, dicData);
|
||||||
|
const SPELLCHECK_HIGHLIGHT = 'blog-editor-spellcheck';
|
||||||
|
const WORD_PATTERN = /[A-Za-z][A-Za-z'’-]{1,}/g;
|
||||||
|
const SKIP_TAGS = new Set(['CODE', 'PRE', 'A', 'KBD', 'SAMP']);
|
||||||
|
const PERSONAL_DICTIONARY_KEY = 'blog-editor-personal-dictionary-v1';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} value
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isWordChar(value) {
|
||||||
|
return /[A-Za-z'’-]/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight editor spellcheck controller using CSS Highlights.
|
||||||
|
*/
|
||||||
|
export class BlogEditorSpellcheck {
|
||||||
|
/**
|
||||||
|
* @param {HTMLElement} root
|
||||||
|
* @param {(message: string, isError?: boolean) => void} updateStatus
|
||||||
|
*/
|
||||||
|
constructor(root, updateStatus) {
|
||||||
|
this.root = root;
|
||||||
|
this.updateStatus = updateStatus;
|
||||||
|
this.enabled = true;
|
||||||
|
this.pendingTimer = 0;
|
||||||
|
this.highlightName = SPELLCHECK_HIGHLIGHT;
|
||||||
|
this.personalWords = new Set();
|
||||||
|
this.supported = typeof CSS !== 'undefined' && typeof CSS.highlights !== 'undefined' && typeof Highlight !== 'undefined';
|
||||||
|
this.loadPersonalDictionary();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {boolean} enabled
|
||||||
|
*/
|
||||||
|
setEnabled(enabled) {
|
||||||
|
this.enabled = enabled;
|
||||||
|
if (!this.supported) {
|
||||||
|
this.updateStatus(enabled ? 'spellcheck unavailable in this renderer' : 'spellcheck off', !enabled ? false : true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
this.clear();
|
||||||
|
this.updateStatus('spellcheck off');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
schedule() {
|
||||||
|
if (!this.supported || !this.enabled) return;
|
||||||
|
window.clearTimeout(this.pendingTimer);
|
||||||
|
this.pendingTimer = window.setTimeout(() => this.refresh(), 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh() {
|
||||||
|
if (!this.supported || !this.enabled) return;
|
||||||
|
const highlight = new Highlight();
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for (const node of this.iterTextNodes()) {
|
||||||
|
const text = node.textContent ?? '';
|
||||||
|
WORD_PATTERN.lastIndex = 0;
|
||||||
|
let match;
|
||||||
|
while ((match = WORD_PATTERN.exec(text)) !== null) {
|
||||||
|
const word = match[0];
|
||||||
|
if (!this.isMisspelledWord(word)) continue;
|
||||||
|
const range = new Range();
|
||||||
|
range.setStart(node, match.index);
|
||||||
|
range.setEnd(node, match.index + word.length);
|
||||||
|
highlight.add(range);
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CSS.highlights.set(this.highlightName, highlight);
|
||||||
|
this.updateStatus(count > 0 ? `spellcheck on · ${count} issue${count === 1 ? '' : 's'}` : 'spellcheck on · clear');
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
if (!this.supported) return;
|
||||||
|
window.clearTimeout(this.pendingTimer);
|
||||||
|
CSS.highlights.delete(this.highlightName);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the personal dictionary from browser storage.
|
||||||
|
*/
|
||||||
|
loadPersonalDictionary() {
|
||||||
|
if (typeof localStorage === 'undefined') return;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(PERSONAL_DICTIONARY_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
const words = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(words)) return;
|
||||||
|
for (const entry of words) {
|
||||||
|
if (typeof entry !== 'string' || !entry.trim()) continue;
|
||||||
|
const word = entry.trim();
|
||||||
|
this.personalWords.add(word);
|
||||||
|
spell.add(word);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.personalWords.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists the personal dictionary to browser storage.
|
||||||
|
*/
|
||||||
|
savePersonalDictionary() {
|
||||||
|
if (typeof localStorage === 'undefined') return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PERSONAL_DICTIONARY_KEY, JSON.stringify([...this.personalWords].sort((a, b) => a.localeCompare(b))));
|
||||||
|
} catch {
|
||||||
|
// Ignore storage quota / privacy errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a word to the personal dictionary and refreshes highlights.
|
||||||
|
* @param {string} word
|
||||||
|
*/
|
||||||
|
addToDictionary(word) {
|
||||||
|
const normalized = typeof word === 'string' ? word.trim() : '';
|
||||||
|
if (!normalized) return;
|
||||||
|
if (this.personalWords.has(normalized)) return;
|
||||||
|
this.personalWords.add(normalized);
|
||||||
|
spell.add(normalized);
|
||||||
|
this.savePersonalDictionary();
|
||||||
|
this.refresh();
|
||||||
|
this.updateStatus(`added "${normalized}" to dictionary`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} word
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
isMisspelledWord(word) {
|
||||||
|
if (!this.shouldCheckWord(word)) return false;
|
||||||
|
return !spell.correct(word);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} word
|
||||||
|
* @param {number} [limit]
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
getSuggestions(word, limit = 6) {
|
||||||
|
if (!word || !this.isMisspelledWord(word)) return [];
|
||||||
|
return spell.suggest(word).slice(0, Math.max(0, limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
*iterTextNodes() {
|
||||||
|
const walker = document.createTreeWalker(this.root, NodeFilter.SHOW_TEXT, {
|
||||||
|
acceptNode: node => {
|
||||||
|
if (!(node.parentElement instanceof HTMLElement)) return NodeFilter.FILTER_REJECT;
|
||||||
|
if (!node.textContent || !WORD_PATTERN.test(node.textContent)) return NodeFilter.FILTER_REJECT;
|
||||||
|
if (node.parentElement.closest('[data-type="image-block"]')) return NodeFilter.FILTER_REJECT;
|
||||||
|
if (this.shouldSkip(node.parentElement)) return NodeFilter.FILTER_REJECT;
|
||||||
|
return NodeFilter.FILTER_ACCEPT;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let current;
|
||||||
|
while ((current = walker.nextNode())) {
|
||||||
|
yield current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {HTMLElement} element
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
shouldSkip(element) {
|
||||||
|
let current = element;
|
||||||
|
while (current && current !== this.root) {
|
||||||
|
if (SKIP_TAGS.has(current.tagName)) return true;
|
||||||
|
if (current.getAttribute('contenteditable') === 'false') return true;
|
||||||
|
current = current.parentElement;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the misspelled word under a viewport point, if any.
|
||||||
|
* @param {number} clientX
|
||||||
|
* @param {number} clientY
|
||||||
|
* @returns {string | null}
|
||||||
|
*/
|
||||||
|
getMisspelledWordAtPoint(clientX, clientY) {
|
||||||
|
const hit = this.getMisspelledAtPoint(clientX, clientY);
|
||||||
|
return hit ? hit.word : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns misspelled word details under a viewport point, if any.
|
||||||
|
* @param {number} clientX
|
||||||
|
* @param {number} clientY
|
||||||
|
* @returns {{ word: string, range: Range } | null}
|
||||||
|
*/
|
||||||
|
getMisspelledAtPoint(clientX, clientY) {
|
||||||
|
if (!this.supported) return null;
|
||||||
|
|
||||||
|
let node = null;
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
if (typeof document.caretPositionFromPoint === 'function') {
|
||||||
|
const pos = document.caretPositionFromPoint(clientX, clientY);
|
||||||
|
if (pos) {
|
||||||
|
node = pos.offsetNode;
|
||||||
|
offset = pos.offset;
|
||||||
|
}
|
||||||
|
} else if (typeof document.caretRangeFromPoint === 'function') {
|
||||||
|
const range = document.caretRangeFromPoint(clientX, clientY);
|
||||||
|
if (range) {
|
||||||
|
node = range.startContainer;
|
||||||
|
offset = range.startOffset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(node instanceof Text)) return null;
|
||||||
|
if (!(node.parentElement instanceof HTMLElement)) return null;
|
||||||
|
if (this.shouldSkip(node.parentElement)) return null;
|
||||||
|
|
||||||
|
const text = node.textContent ?? '';
|
||||||
|
if (!text) return null;
|
||||||
|
|
||||||
|
let start = Math.min(Math.max(offset, 0), text.length);
|
||||||
|
let end = start;
|
||||||
|
|
||||||
|
if (start > 0 && !isWordChar(text[start]) && isWordChar(text[start - 1])) {
|
||||||
|
start -= 1;
|
||||||
|
end = start;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (start > 0 && isWordChar(text[start - 1])) start -= 1;
|
||||||
|
while (end < text.length && isWordChar(text[end])) end += 1;
|
||||||
|
|
||||||
|
if (end <= start) return null;
|
||||||
|
|
||||||
|
const word = text.slice(start, end);
|
||||||
|
if (!this.isMisspelledWord(word)) return null;
|
||||||
|
|
||||||
|
const range = new Range();
|
||||||
|
range.setStart(node, start);
|
||||||
|
range.setEnd(node, end);
|
||||||
|
return { word, range };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} word
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
shouldCheckWord(word) {
|
||||||
|
if (word.length < 3) return false;
|
||||||
|
if (/^[A-Z0-9_-]+$/.test(word)) return false;
|
||||||
|
if (/\d/.test(word)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
tools/navLinks.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const GITHUB_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>';
|
||||||
|
const BLUESKY_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037-.856 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.196-7.28-1.304-7.847-2.97-.104-.305-.152-.448-.153-.327 0-.121-.05.022-.153.327-.568 1.666-2.782 8.166-7.847 2.97-2.667-2.737-1.432-5.473 3.422-6.3-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026"/></svg>';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} NavLink
|
||||||
|
* @property {string} label
|
||||||
|
* @property {string} href
|
||||||
|
* @property {boolean} [external]
|
||||||
|
* @property {string} [svg] - Inline SVG string shown instead of label text.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {NavLink[]} */
|
||||||
|
const NAV_LINKS = [
|
||||||
|
{ label: 'home', href: '/' },
|
||||||
|
{ label: 'blog', href: '/blog.html' },
|
||||||
|
{ label: 'docs', href: '/docs/' },
|
||||||
|
{ label: 'materials', href: '/materials' },
|
||||||
|
{ label: 'GitHub', href: 'https://github.com/litruv', external: true, svg: GITHUB_SVG },
|
||||||
|
{ label: 'Bluesky', href: 'https://bsky.app/profile/lit.mates.dev', external: true, svg: BLUESKY_SVG },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders nav link `<a>` elements as an HTML string.
|
||||||
|
*
|
||||||
|
* @param {string} [indent=' '] - Leading whitespace for each line.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function renderNavLinkItems(indent = ' ') {
|
||||||
|
return NAV_LINKS.map(({ label, href, external, svg }) => {
|
||||||
|
const attrs = external ? ' target="_blank" rel="noopener"' : '';
|
||||||
|
const content = svg ? svg : label;
|
||||||
|
const extraClass = svg ? ' quick-link--icon' : '';
|
||||||
|
return `${indent}<a href="${href}"${attrs} class="quick-link${extraClass}" aria-label="${label}"${svg ? ` title="${label}"` : ''}>${content}</a>`;
|
||||||
|
}).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { NAV_LINKS, renderNavLinkItems };
|
||||||
|
Before Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 379 B |
|
Before Width: | Height: | Size: 310 B |
|
Before Width: | Height: | Size: 367 B |
|
Before Width: | Height: | Size: 406 B |
|
Before Width: | Height: | Size: 208 B |
|
Before Width: | Height: | Size: 470 B |
|
Before Width: | Height: | Size: 438 B |
|
Before Width: | Height: | Size: 813 B |
|
Before Width: | Height: | Size: 476 B |
|
Before Width: | Height: | Size: 462 B |
|
Before Width: | Height: | Size: 958 B |
|
Before Width: | Height: | Size: 225 B |
|
Before Width: | Height: | Size: 375 B |
|
Before Width: | Height: | Size: 321 B |
|
Before Width: | Height: | Size: 513 B |
|
Before Width: | Height: | Size: 506 B |
|
Before Width: | Height: | Size: 273 B |
|
Before Width: | Height: | Size: 430 B |
|
Before Width: | Height: | Size: 321 B |
|
Before Width: | Height: | Size: 144 B |
|
Before Width: | Height: | Size: 165 B |
|
Before Width: | Height: | Size: 154 B |
|
Before Width: | Height: | Size: 199 B |
|
Before Width: | Height: | Size: 108 B |
|
Before Width: | Height: | Size: 128 B |
|
Before Width: | Height: | Size: 187 B |
|
Before Width: | Height: | Size: 306 B |
|
Before Width: | Height: | Size: 242 B |
|
Before Width: | Height: | Size: 364 B |
|
Before Width: | Height: | Size: 534 B |
|
Before Width: | Height: | Size: 414 B |
|
Before Width: | Height: | Size: 584 B |
|
Before Width: | Height: | Size: 368 B |
|
Before Width: | Height: | Size: 257 B |
|
Before Width: | Height: | Size: 674 B |
|
Before Width: | Height: | Size: 568 B |
|
Before Width: | Height: | Size: 632 B |
|
Before Width: | Height: | Size: 248 B |
|
Before Width: | Height: | Size: 405 B |
|
Before Width: | Height: | Size: 570 B |
|
Before Width: | Height: | Size: 349 B |
|
Before Width: | Height: | Size: 675 B |
|
Before Width: | Height: | Size: 696 B |
|
Before Width: | Height: | Size: 407 B |
|
Before Width: | Height: | Size: 386 B |
|
Before Width: | Height: | Size: 550 B |
|
Before Width: | Height: | Size: 287 B |
|
Before Width: | Height: | Size: 206 B |
|
Before Width: | Height: | Size: 335 B |
|
Before Width: | Height: | Size: 168 B |
|
Before Width: | Height: | Size: 102 B |
|
Before Width: | Height: | Size: 115 B |
|
Before Width: | Height: | Size: 123 B |
|
Before Width: | Height: | Size: 140 B |
|
Before Width: | Height: | Size: 143 B |
|
Before Width: | Height: | Size: 159 B |
|
Before Width: | Height: | Size: 160 B |
|
Before Width: | Height: | Size: 174 B |
|
Before Width: | Height: | Size: 180 B |
|
Before Width: | Height: | Size: 180 B |
|
Before Width: | Height: | Size: 469 B |
|
Before Width: | Height: | Size: 262 B |
|
Before Width: | Height: | Size: 266 B |
|
Before Width: | Height: | Size: 740 B |
|
Before Width: | Height: | Size: 823 B |
|
Before Width: | Height: | Size: 495 B |
|
Before Width: | Height: | Size: 749 B |
|
Before Width: | Height: | Size: 660 B |
|
Before Width: | Height: | Size: 629 B |
|
Before Width: | Height: | Size: 666 B |
|
Before Width: | Height: | Size: 707 B |
|
Before Width: | Height: | Size: 639 B |