blog post editor

This commit is contained in:
2026-05-10 19:09:26 +10:00
parent 40fd8e8e42
commit 0f59de54bb
26 changed files with 6049 additions and 625 deletions

111
build.js
View File

@@ -1,5 +1,7 @@
const fs = require('fs').promises;
const path = require('path');
const esbuild = require('esbuild');
const { execSync } = require('child_process');
/**
* Parse YAML front matter from markdown content
@@ -174,6 +176,54 @@ async function updateGraphWithBlogNodes(posts, graphPath) {
console.log(`✓ Added ${blogNodes.length} blog node(s) to graph`);
}
/**
* Bundle all JavaScript files into a single bundle.js using esbuild
*/
async function bundleJavaScript() {
const entryPoint = path.join(__dirname, 'website', 'scripts', 'main.js');
const outfile = path.join(__dirname, 'build', 'scripts', 'bundle.js');
const scriptsDir = path.join(__dirname, 'build', 'scripts');
try {
await esbuild.build({
entryPoints: [entryPoint],
bundle: true,
outfile: outfile,
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 !== 'bundle.js') {
await fs.unlink(fullPath);
}
}
}
await removeJsFiles(scriptsDir);
console.log('✓ Removed individual JS files');
} catch (error) {
console.error('✗ JavaScript bundling failed:', error);
throw error;
}
}
// Copy files recursively from website to build
async function copyWebsiteFiles() {
const websiteDir = path.join(__dirname, 'website');
@@ -215,10 +265,71 @@ async function copyWebsiteFiles() {
}
}
/**
* Minify CSS files in the build directory
*/
async function minifyCSS() {
const stylesDir = path.join(__dirname, 'build', 'styles');
try {
const files = await fs.readdir(stylesDir);
const cssFiles = files.filter(f => f.endsWith('.css'));
for (const file of cssFiles) {
const filePath = path.join(stylesDir, file);
const outputPath = filePath;
execSync(`npx cleancss -o "${outputPath}" "${filePath}"`, {
cwd: __dirname,
stdio: 'pipe'
});
}
console.log(`✓ Minified ${cssFiles.length} CSS file(s)`);
} catch (error) {
console.error('✗ CSS minification failed:', error);
throw error;
}
}
/**
* Update HTML files to use bundled JavaScript
*/
async function updateHtmlForBundle() {
const htmlPath = path.join(__dirname, 'build', 'index.html');
try {
let html = await fs.readFile(htmlPath, 'utf-8');
// Replace module script tag with bundle script tag (keep type="module" for esm format)
html = html.replace(
/<script type="module" src="scripts\/main\.js"><\/script>/,
'<script type="module" src="scripts/bundle.js"></script>'
);
await fs.writeFile(htmlPath, html);
console.log('✓ Updated HTML to use bundle.js');
} catch (error) {
console.error('✗ Failed to update HTML:', error);
throw error;
}
}
// Run the build
async function build() {
await processBlogPosts();
await copyWebsiteFiles();
await bundleJavaScript();
await minifyCSS();
await updateHtmlForBundle();
// Report final sizes
console.log('\n=== Build Summary ===');
const bundleStats = await fs.stat(path.join(__dirname, 'build', 'scripts', 'bundle.js'));
const cssStats = await fs.stat(path.join(__dirname, 'build', 'styles', 'main.css'));
console.log(`bundle.js: ${(bundleStats.size / 1024).toFixed(2)} KB`);
console.log(`main.css: ${(cssStats.size / 1024).toFixed(2)} KB`);
console.log('=====================\n');
}
build();

3822
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,8 @@
"main": "index.html",
"scripts": {
"start": "python3 -m http.server 8000",
"start-alt": "python -m SimpleHTTPServer 8000"
"start-alt": "python -m SimpleHTTPServer 8000",
"blog-editor": "node tools/blog-editor.js"
},
"keywords": [
"terminal",
@@ -27,5 +28,10 @@
"type": "git",
"url": "https://github.com/litruv/lit.ruv.wtf"
},
"homepage": "https://lit.ruv.wtf"
"homepage": "https://lit.ruv.wtf",
"devDependencies": {
"@milkdown/crepe": "^7.20.0",
"clean-css-cli": "^5.6.3",
"esbuild": "^0.28.0"
}
}

View File

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

Binary file not shown.

View File

@@ -0,0 +1,83 @@
'use strict';
/**
* Pre-builds the Milkdown Crepe JS + CSS bundles into .vscode/dist/
* Run this before packaging the VSIX: node .vscode/build-dist.js
*/
const path = require('path');
const fs = require('fs');
// Resolve symlinks so node_modules lookup works even in junction'd paths
const ROOT = fs.realpathSync(__dirname);
const DIST_DIR = path.join(ROOT, 'dist');
/**
* Walks up from startDir to find node_modules containing a given package.
* Returns null if not found (never throws).
* @param {string} pkg
* @param {string} startDir
* @returns {string | null}
*/
function resolveFromNearestNodeModules(pkg, startDir) {
let dir = startDir;
while (true) {
const candidate = path.join(dir, 'node_modules', pkg);
if (fs.existsSync(candidate)) return candidate;
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
// Walk up from __dirname until we find node_modules with esbuild.
// Also tries process.cwd() in case the script is in a stub folder without node_modules.
const esbuildEntry = resolveFromNearestNodeModules('esbuild', ROOT)
?? resolveFromNearestNodeModules('esbuild', process.cwd());
if (!esbuildEntry) throw new Error('Cannot find esbuild. Run this from the project root that contains node_modules.');
const esbuild = require(path.join(esbuildEntry, 'lib', 'main.js'));
const crepeLib = resolveFromNearestNodeModules('@milkdown/crepe', ROOT)
?? resolveFromNearestNodeModules('@milkdown/crepe', process.cwd());
const CREPE_DIR = path.join(crepeLib, 'lib', 'theme');
// Also need the root for esbuild resolveDir (must contain node_modules/@milkdown)
const ROOT_DIR = path.dirname(path.dirname(esbuildEntry));
const FONT_LOADERS = {
'.woff2': 'dataurl',
'.woff': 'dataurl',
'.ttf': 'dataurl',
'.eot': 'dataurl',
'.svg': 'dataurl',
};
async function main() {
fs.mkdirSync(DIST_DIR, { recursive: true });
console.log('⏳ Bundling Milkdown JS…');
const jsResult = await esbuild.build({
stdin: { contents: `export { Crepe, CrepeFeature } from '@milkdown/crepe';`, resolveDir: ROOT_DIR, loader: 'js' },
bundle: true, format: 'esm', platform: 'browser', target: 'es2022', minify: true, write: false,
});
fs.writeFileSync(path.join(DIST_DIR, 'milkdown.js'), jsResult.outputFiles[0].contents);
console.log(`✓ milkdown.js ${(jsResult.outputFiles[0].contents.byteLength / 1024).toFixed(0)} KB`);
console.log('⏳ Bundling common CSS…');
const cssCommon = await esbuild.build({
entryPoints: [path.join(CREPE_DIR, 'common', 'style.css')],
bundle: true, write: false, loader: FONT_LOADERS,
});
fs.writeFileSync(path.join(DIST_DIR, 'milkdown-common.css'), cssCommon.outputFiles[0].contents);
console.log(`✓ milkdown-common.css ${(cssCommon.outputFiles[0].contents.byteLength / 1024).toFixed(0)} KB`);
console.log('⏳ Bundling theme CSS…');
const cssTheme = await esbuild.build({
entryPoints: [path.join(CREPE_DIR, 'frame-dark', 'style.css')],
bundle: true, write: false, loader: FONT_LOADERS,
});
fs.writeFileSync(path.join(DIST_DIR, 'milkdown-theme.css'), cssTheme.outputFiles[0].contents);
console.log(`✓ milkdown-theme.css ${(cssTheme.outputFiles[0].contents.byteLength / 1024).toFixed(0)} KB`);
console.log('\n✓ dist/ ready — run: npx @vscode/vsce package --out blog-editor.vsix --no-dependencies');
}
main().catch(err => { console.error(err); process.exit(1); });

View File

@@ -0,0 +1,755 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy"
content="default-src 'none';
script-src 'nonce-{{NONCE}}' {{CSP_SOURCE}};
connect-src {{CSP_SOURCE}} blob: https:;
style-src {{CSP_SOURCE}} 'unsafe-inline';
img-src {{CSP_SOURCE}} data: blob: https:;
font-src {{CSP_SOURCE}} data:;">
<title>Blog Editor</title>
<link rel="stylesheet" href="{{COMMON_CSS}}">
<link rel="stylesheet" href="{{THEME_CSS}}">
<style>
:root {
--bg: #11111b; --mantle: #181825; --surface0: #313244;
--surface1: #45475a; --surface2: #585b70; --text: #cdd6f4;
--sub0: #a6adc8; --sub1: #bac2de; --blue: #89b4fa;
--green: #a6e3a1; --red: #f38ba8; --peach: #fab387;
--mauve: #cba6f7; --sky: #89dceb; --teal: #94e2d5;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
font-size: 14px;
background: var(--bg);
color: var(--text);
height: 100vh;
display: block;
overflow: hidden;
}
/* ── Sidebar ─────────────────────────────────────────────────────────── */
#sidebar {
background: var(--mantle);
border-right: 1px solid var(--surface0);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-header {
padding: 12px;
border-bottom: 1px solid var(--surface0);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.sidebar-title {
font-size: 0.78rem;
font-weight: 600;
color: var(--sub0);
text-transform: uppercase;
letter-spacing: 0.08em;
}
#btn-new {
background: var(--surface0);
color: var(--blue);
border: 1px solid var(--surface1);
border-radius: 4px;
padding: 3px 8px;
font-size: 0.75rem;
cursor: pointer;
}
#btn-new:hover { background: var(--surface1); }
#post-list {
list-style: none;
overflow-y: auto;
flex: 1;
padding: 6px 0;
}
#post-list li {
padding: 6px 12px;
cursor: pointer;
border-radius: 4px;
margin: 1px 6px;
font-size: 0.82rem;
color: var(--sub1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#post-list li:hover { background: var(--surface0); color: var(--text); }
#post-list li.active { background: var(--surface0); color: var(--blue); font-weight: 600; }
/* ── Main ────────────────────────────────────────────────────────────── */
#main {
display: grid;
grid-template-rows: auto 1fr auto;
overflow: hidden;
background: var(--bg);
width: 100%;
height: 100vh;
min-width: 0;
min-height: 0;
}
#frontmatter {
display: flex;
gap: 8px;
padding: 8px;
background: var(--mantle);
border-bottom: 1px solid var(--surface0);
flex-wrap: wrap;
flex-shrink: 0;
align-items: stretch;
}
.frontmatter-fields {
display: flex;
gap: 8px;
flex: 1;
flex-wrap: wrap;
min-width: 0;
}
.fm-field {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
background: var(--surface0);
border: 1px solid var(--surface1);
border-radius: 6px;
padding: 0 8px;
height: 34px;
}
.fm-label {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
font-size: 0.82rem;
font-weight: 700;
color: var(--sub0);
}
.fm-icon {
width: 1.1em;
text-align: center;
opacity: 0.9;
cursor: help;
}
#frontmatter input {
background: transparent;
border: none;
color: var(--text);
border-radius: 0;
padding: 0;
font-size: 0.8rem;
outline: none;
font-family: inherit;
width: 100%;
min-width: 0;
height: 100%;
}
.fm-field:focus-within { border-color: var(--blue); }
.fm-field.slug-field { flex: 1 1 170px; min-width: 170px; }
.fm-field.title-field { flex: 2.2 1 280px; min-width: 220px; }
.fm-field.date-field { flex: 0.8 1 150px; min-width: 150px; }
.fm-field.author-field { flex: 1.1 1 180px; min-width: 160px; }
.fm-field.tags-field { flex: 1.8 1 240px; min-width: 200px; }
#fm-slug { font-family: "Cascadia Code", "Fira Code", ui-monospace, monospace; font-size: 0.75rem; color: var(--sub0); }
#fm-date { color-scheme: dark; }
/* ── Editor area ─────────────────────────────────────────────────────── */
#editor-wrap {
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
min-width: 0;
flex: 1 1 auto;
display: flex;
flex-direction: column;
}
/* ── Milkdown host ───────────────────────────────────────────────────── */
.milkdown-root, .milkdown {
flex: 1;
min-height: 0;
background: var(--bg) !important;
font-family: system-ui, -apple-system, sans-serif !important;
overflow: visible !important;
}
.milkdown .editor {
padding: 40px 56px;
max-width: 860px;
margin: 0 auto;
min-height: calc(100% - 1px);
font-family: system-ui, -apple-system, sans-serif !important;
font-size: 14px;
color: var(--sub1);
caret-color: var(--mauve);
padding-bottom: 96px;
}
.milkdown .editor *:not(pre):not(code):not(pre *):not(code *):not(.cm-editor):not(.cm-editor *) {
font-family: system-ui, -apple-system, sans-serif !important;
}
/* ── Headings ─────────────────────────────────────────────────────────── */
.milkdown .editor h1,
.milkdown .editor h2,
.milkdown .editor h3,
.milkdown .editor h4,
.milkdown .editor h5,
.milkdown .editor h6 {
color: var(--text);
font-weight: 700;
margin-top: 0.9em;
margin-bottom: 0.3em;
letter-spacing: 0.01em;
}
.milkdown .editor h1 { font-size: 1.75rem; }
.milkdown .editor h2 { font-size: 1.25rem; }
.milkdown .editor h3 { font-size: 1.05rem; }
.milkdown .editor h4 { font-size: 0.95rem; }
.milkdown .editor h5 { font-size: 0.88rem; }
.milkdown .editor h6 { font-size: 0.82rem; }
/* ── Body text ────────────────────────────────────────────────────────── */
.milkdown .editor p { margin: 0.45em 0; line-height: 1.6; }
/* ── Links ────────────────────────────────────────────────────────────── */
.milkdown .editor a { color: var(--blue); text-decoration: none; }
.milkdown .editor a:hover { text-decoration: underline; opacity: 0.85; }
/* ── Strong / em ──────────────────────────────────────────────────────── */
.milkdown .editor strong { color: var(--text); font-weight: 700; }
.milkdown .editor em { color: var(--sub1); font-style: italic; }
/* ── Lists ────────────────────────────────────────────────────────────── */
.milkdown .editor ul,
.milkdown .editor ol { margin: 0.3em 0 0.3em 1.3em; padding: 0; }
.milkdown .editor li { margin: 0.18em 0; line-height: 1.6; }
/* ── Blockquote ───────────────────────────────────────────────────────── */
.milkdown .editor blockquote {
border-left: 3px solid rgba(250, 179, 135, 0.5);
padding: 0.2em 0.7em;
margin: 0.5em 0;
color: rgba(186, 194, 222, 0.7);
font-style: italic;
}
/* ── Inline code ──────────────────────────────────────────────────────── */
.milkdown .editor :not(pre) > code {
font-family: "Cascadia Code", "Fira Code", ui-monospace, monospace !important;
font-size: 0.82em;
background: rgba(49, 50, 68, 0.6);
border-radius: 3px;
padding: 0.1em 0.35em;
color: var(--sky);
}
/* ── Code blocks ──────────────────────────────────────────────────────── */
.milkdown .editor pre,
.milkdown .editor code,
.milkdown .editor pre *,
.milkdown .editor code *,
.milkdown .editor .cm-editor,
.milkdown .editor .cm-editor * {
font-family: "Cascadia Code", "Fira Code", ui-monospace, monospace !important;
}
.milkdown .editor pre {
background: rgba(17, 17, 27, 0.7);
border: 1px solid var(--surface1);
border-radius: 6px;
padding: 0.6em 0.9em;
overflow-x: auto;
margin: 0.5em 0;
font-size: 0.82em;
color: var(--sky);
line-height: 1.55;
}
.milkdown .editor pre code { background: none; padding: 0; border-radius: 0; color: inherit; }
/* ── Horizontal rule ──────────────────────────────────────────────────── */
.milkdown .editor hr { border: none; border-top: 1px solid var(--surface1); margin: 0.8em 0; }
/* ── Images ───────────────────────────────────────────────────────────── */
.milkdown .editor img { max-width: 100%; height: auto; border-radius: 6px; display: block; margin: 0.6em auto; }
/* ── Tables ───────────────────────────────────────────────────────────── */
.milkdown .editor table { border-collapse: collapse; width: 100%; margin: 0.5em 0; font-size: 0.88em; }
.milkdown .editor th { background: var(--surface0); color: var(--text); font-weight: 600; padding: 0.4em 0.7em; border: 1px solid var(--surface1); text-align: left; }
.milkdown .editor td { padding: 0.35em 0.7em; border: 1px solid var(--surface1); color: var(--sub1); }
.milkdown .editor tr:nth-child(even) td { background: rgba(49, 50, 68, 0.25); }
/* ── Milkdown toolbar chrome ──────────────────────────────────────────── */
.milkdown-menu { background: var(--mantle) !important; border-bottom: 1px solid var(--surface0) !important; }
.milkdown-menu button { color: var(--sub0) !important; }
.milkdown-menu button:hover { color: var(--text) !important; background: var(--surface0) !important; }
.milkdown-menu button.active { color: var(--mauve) !important; }
/* ── Status bar ──────────────────────────────────────────────────────── */
#statusbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 5px 12px;
background: var(--mantle);
border-top: 1px solid var(--surface0);
font-size: 0.75rem;
color: var(--sub0);
flex-shrink: 0;
min-width: 0;
width: 100%;
overflow: hidden;
}
#status {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-actions {
display: inline-flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
#status.dirty { color: var(--peach); }
#status.saved { color: var(--green); }
#btn-save {
background: transparent;
color: var(--blue);
border: none;
border-radius: 6px;
width: 30px;
height: 28px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
line-height: 1;
}
#btn-save:hover { background: rgba(137, 180, 250, 0.12); opacity: 1; }
#btn-save:disabled { background: transparent; color: var(--sub0); cursor: default; opacity: 1; }
</style>
</head>
<body>
<div id="main">
<div id="frontmatter">
<div class="frontmatter-fields">
<label class="fm-field slug-field" title="Post slug (filename)">
<span class="fm-label" title="Post slug (filename)"><span class="fm-icon" title="Post slug (filename)">#</span></span>
<input id="fm-slug" placeholder="Slug" title="Post slug (filename)" aria-label="Slug" />
</label>
<label class="fm-field title-field" title="Post title">
<span class="fm-label" title="Post title"><span class="fm-icon" title="Post title">📝</span></span>
<input id="fm-title" placeholder="Title" title="Post title" aria-label="Title" />
</label>
<label class="fm-field date-field" title="Publish date">
<span class="fm-label" title="Publish date"><span class="fm-icon" title="Publish date">🗓</span></span>
<input id="fm-date" type="date" title="Publish date" aria-label="Date" />
</label>
<label class="fm-field author-field" title="Post author">
<span class="fm-label" title="Post author"><span class="fm-icon" title="Post author">👤</span></span>
<input id="fm-author" placeholder="Author" title="Post author" aria-label="Author" />
</label>
<label class="fm-field tags-field" title="Comma-separated tags">
<span class="fm-label" title="Comma-separated tags"><span class="fm-icon" title="Comma-separated tags">🏷</span></span>
<input id="fm-tags" placeholder="Tags" title="Comma-separated tags" aria-label="Tags" />
</label>
</div>
</div>
<div id="editor-wrap"></div>
<div id="statusbar">
<span id="status">No file open</span>
<div class="status-actions">
<button id="btn-save" disabled title="Save (Ctrl+S)" aria-label="Save">&#128190;</button>
</div>
</div>
</div>
<script type="module" nonce="{{NONCE}}">
import * as Milkdown from '{{MILKDOWN_JS}}';
const Crepe = Milkdown.Crepe;
const imageBlockFeature = Milkdown.CrepeFeature?.ImageBlock ?? 'image-block';
const vscode = acquireVsCodeApi();
const editorWrap = /** @type {HTMLDivElement} */ (document.getElementById('editor-wrap'));
const status = /** @type {HTMLSpanElement} */ (document.getElementById('status'));
const btnSave = /** @type {HTMLButtonElement} */ (document.getElementById('btn-save'));
const fmTitle = /** @type {HTMLInputElement} */ (document.getElementById('fm-title'));
const fmSlug = /** @type {HTMLInputElement} */ (document.getElementById('fm-slug'));
const fmDate = /** @type {HTMLInputElement} */ (document.getElementById('fm-date'));
const fmAuthor = /** @type {HTMLInputElement} */ (document.getElementById('fm-author'));
const fmTags = /** @type {HTMLInputElement} */ (document.getElementById('fm-tags'));
/** @type {string | null} */ let currentSlug = null;
/** @type {import('{{MILKDOWN_JS}}').Crepe | null} */ let crepe = null;
let dirty = false;
/** @type {((slug: string) => void) | null} */ let _pendingRenameResolve = null;
/** @param {string | undefined} slug @returns {boolean} */
function isValidSlug(slug) {
return typeof slug === 'string' && /^[a-z0-9][a-z0-9-]*$/.test(slug) && slug.length <= 100;
}
// -- Image handling -------------------------------------------------------
// Relative image paths are rewritten to webview resource URIs when a post
// is loaded (imageMap: webviewUri -> relativePath). Pasted/dropped images
// arrive as blob: URLs with no mapping. On save we fetch each unknown blob,
// upload it to the extension, await mediaSaved, then write clean paths.
/** webviewUri -> relativePath */
const webviewToRelative = new Map();
/** blobUrl -> relativePath, populated after mediaSaved */
const blobToRelative = new Map();
/** blobUrl -> Promise<void>, de-dupes concurrent saves for same blob */
const _blobSavePromises = new Map();
/** blobUrl -> resolve(), resolved when mediaSaved arrives */
const _blobSaveResolvers = new Map();
/** uploadId -> resolve(relativePath) */
const _uploadResolvers = new Map();
/**
* Uploads a blob URL to the extension to save to media/.
* Returns a Promise that resolves once mediaSaved is received.
* @param {string} blobUrl
* @returns {Promise<void>}
*/
function saveNewMediaAndWait(blobUrl) {
if (blobToRelative.has(blobUrl)) return Promise.resolve();
if (_blobSavePromises.has(blobUrl)) return /** @type {Promise<void>} */ (_blobSavePromises.get(blobUrl));
const p = new Promise(async (resolve) => {
_blobSaveResolvers.set(blobUrl, resolve);
try {
const resp = await fetch(blobUrl);
const blob = await resp.blob();
const ext = (blob.type.split('/')[1] ?? 'png').split('+')[0].replace(/[^a-z0-9]/gi, '') || 'png';
const name = `paste-${Date.now()}-${Math.random().toString(36).slice(2, 7)}.${ext}`;
const bytes = new Uint8Array(await blob.arrayBuffer());
let b64 = '';
for (let i = 0; i < bytes.length; i += 8192) {
b64 += String.fromCharCode(...bytes.subarray(i, i + 8192));
}
send('saveMedia', { blobUrl, dataBase64: btoa(b64), mimeType: blob.type, filename: name });
} catch (e) {
console.error('[Blog Editor] Failed to upload pasted image:', e);
_blobSaveResolvers.delete(blobUrl);
_blobSavePromises.delete(blobUrl);
resolve();
}
});
_blobSavePromises.set(blobUrl, p);
return p;
}
/**
* Uploads a File and resolves with a persistent relative path.
* Used by Milkdown image uploads so markdown never stores blob: URLs.
* @param {File} file
* @returns {Promise<string>}
*/
async function uploadImageFile(file) {
console.log('[Blog Editor] uploadImageFile called:', file.name, file.type);
const extFromType = (file.type.split('/')[1] ?? '').split('+')[0].replace(/[^a-z0-9]/gi, '').toLowerCase();
const extFromName = (file.name.split('.').pop() ?? '').replace(/[^a-z0-9]/gi, '').toLowerCase();
const ext = extFromType || extFromName || 'png';
const uploadId = `upload:${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const bytes = new Uint8Array(await file.arrayBuffer());
let b64 = '';
for (let i = 0; i < bytes.length; i += 8192) {
b64 += String.fromCharCode(...bytes.subarray(i, i + 8192));
}
const filename = `paste-${Date.now()}-${Math.random().toString(36).slice(2, 7)}.${ext}`;
console.log('[Blog Editor] Sending saveMedia:', filename);
send('saveMedia', { blobUrl: uploadId, dataBase64: btoa(b64), mimeType: file.type, filename });
return new Promise(resolve => {
const timeout = setTimeout(() => {
console.warn('[Blog Editor] uploadImageFile timeout - no response after 10s:', uploadId);
resolve(`data/blog/media/${filename}`);
}, 10000);
_uploadResolvers.set(uploadId, (path) => {
clearTimeout(timeout);
console.log('[Blog Editor] uploadImageFile resolved:', path);
resolve(path);
});
});
}
/**
* Converts any remaining blob: URLs to file paths by uploading them.
* This is a safety fallback for blobs that Crepe inserted before onUpload was called.
* @param {string} markdown
* @returns {Promise<string>}
*/
async function uploadRemainingBlobs(markdown) {
const blobMatches = [...markdown.matchAll(/!\[([^\]]*)\]\((blob:[^)]+)\)/g)];
if (blobMatches.length === 0) return markdown;
console.warn(`[Blog Editor] Found ${blobMatches.length} remaining blob URLs - uploading them`);
const uploads = blobMatches.map(async m => {
const blobUrl = m[2];
if (blobToRelative.has(blobUrl)) return { blobUrl, relPath: blobToRelative.get(blobUrl) };
try {
const resp = await fetch(blobUrl);
const blob = await resp.blob();
const ext = (blob.type.split('/')[1] ?? 'png').split('+')[0].replace(/[^a-z0-9]/gi, '') || 'png';
const filename = `paste-${Date.now()}-${Math.random().toString(36).slice(2, 7)}.${ext}`;
const bytes = new Uint8Array(await blob.arrayBuffer());
let b64 = '';
for (let i = 0; i < bytes.length; i += 8192) {
b64 += String.fromCharCode(...bytes.subarray(i, i + 8192));
}
console.log('[Blog Editor] Uploading leftover blob:', filename);
return await new Promise(resolve => {
send('saveMedia', { blobUrl, dataBase64: btoa(b64), mimeType: blob.type, filename });
const timeout = setTimeout(() => {
console.warn('[Blog Editor] Leftover blob upload timeout:', blobUrl);
resolve({ blobUrl, relPath: `data/blog/media/${filename}` });
}, 5000);
const checkInterval = setInterval(() => {
if (blobToRelative.has(blobUrl)) {
clearTimeout(timeout);
clearInterval(checkInterval);
resolve({ blobUrl, relPath: blobToRelative.get(blobUrl) });
}
}, 100);
});
} catch (e) {
console.error('[Blog Editor] Failed to upload leftover blob:', blobUrl, e);
return { blobUrl, relPath: null };
}
});
const results = await Promise.all(uploads);
let result = markdown;
for (const { blobUrl, relPath } of results) {
if (relPath) {
result = result.replace(new RegExp(`!\\[([^\\]]*)\\]\\(${blobUrl.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')}\\)`, 'g'), `![$1](${relPath})`);
}
}
return result;
}
/**
* Replaces blob/webview URIs in markdown with relative paths.
* Call only after all saveNewMediaAndWait() promises have settled.
* @param {string} markdown
* @returns {string}
*/
function cleanImagePaths(markdown) {
return markdown.replace(/(\!\[[^\]]*\]\()([^)]+)(\))/g, (_m, open, src, close) => {
const rel = blobToRelative.get(src) ?? webviewToRelative.get(src);
return rel ? `${open}${rel}${close}` : `${open}${src}${close}`;
});
}
// -- Messaging ------------------------------------------------------------
/** @param {string} type @param {Record<string, unknown>} [payload] */
function send(type, payload = {}) { vscode.postMessage({ type, ...payload }); }
window.addEventListener('message', async e => {
const msg = e.data;
console.log('[Blog Editor] Received message:', msg.type);
switch (msg.type) {
case 'openSlug': requestPost(msg.slug); break;
case 'post':
console.log('[Blog Editor] Loading post:', msg.slug, 'Image map:', msg.imageMap ? Object.keys(msg.imageMap).length : 0);
webviewToRelative.clear();
blobToRelative.clear();
if (msg.imageMap) {
for (const [uri, rel] of Object.entries(msg.imageMap)) webviewToRelative.set(uri, rel);
}
await onPostLoaded(msg.slug, msg.content);
break;
case 'saved': markClean(); break;
case 'renamed': {
currentSlug = msg.newSlug;
fmSlug.value = msg.newSlug;
if (_pendingRenameResolve) { _pendingRenameResolve(msg.newSlug); _pendingRenameResolve = null; }
break;
}
case 'mediaSaved': {
console.log('[Blog Editor] mediaSaved:', msg.blobUrl, '->', msg.relativePath);
blobToRelative.set(msg.blobUrl, msg.relativePath);
webviewToRelative.set(msg.webviewUri, msg.relativePath);
const resolveUpload = _uploadResolvers.get(msg.blobUrl);
if (resolveUpload) {
_uploadResolvers.delete(msg.blobUrl);
resolveUpload(msg.relativePath);
}
_blobSavePromises.delete(msg.blobUrl);
const resolve = _blobSaveResolvers.get(msg.blobUrl);
if (resolve) { _blobSaveResolvers.delete(msg.blobUrl); resolve(); }
markDirty();
break;
}
case 'created': send('refreshPosts'); await requestPost(msg.slug); break;
case 'error': console.error('[Blog Editor] Error:', msg.message); alert(msg.message); break;
}
});
// -- Frontmatter ----------------------------------------------------------
/**
* @param {string} raw
* @returns {{ meta: Record<string, string>, body: string }}
*/
function parseFrontmatter(raw) {
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!m) return { meta: {}, body: raw };
const meta = {};
for (const line of m[1].split(/\r?\n/)) {
const i = line.indexOf(':');
if (i > 0) meta[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/^["']|["']$/g, '');
}
return { meta, body: m[2] };
}
/** @returns {string} */
function buildDocument() {
const fields = [
['title', fmTitle.value.trim()],
['date', fmDate.value.trim()],
['author', fmAuthor.value.trim()],
['tags', fmTags.value.trim()],
].filter(([, v]) => v);
const body = crepe ? crepe.getMarkdown() : '';
if (fields.length === 0) return body;
return `---\n${fields.map(([k, v]) => `${k}: ${v}`).join('\n')}\n---\n${body}`;
}
// -- Dirty state ----------------------------------------------------------
function markDirty() {
if (!currentSlug) return;
dirty = true;
status.textContent = `${currentSlug}.md · unsaved`;
status.className = 'dirty';
btnSave.disabled = false;
}
function markClean() {
dirty = false;
status.textContent = `${currentSlug}.md · saved`;
status.className = 'saved';
}
// -- Editor lifecycle -----------------------------------------------------
/**
* @param {string} markdown
*/
async function mountEditor(markdown) {
if (crepe) { crepe.destroy(); crepe = null; }
editorWrap.innerHTML = '';
crepe = new Crepe({
root: editorWrap,
defaultValue: markdown,
features: {
[imageBlockFeature]: {
onUpload: uploadImageFile,
},
},
});
crepe.on(api => { api.markdownUpdated(() => markDirty()); });
await crepe.create();
}
// -- Post loading ---------------------------------------------------------
/**
* @param {string} slug
* @param {string} content
*/
async function onPostLoaded(slug, content) {
const { meta, body } = parseFrontmatter(content);
fmTitle.value = meta.title ?? '';
fmDate.value = meta.date ?? '';
fmAuthor.value = meta.author ?? '';
fmTags.value = meta.tags ?? '';
currentSlug = slug;
fmSlug.value = slug;
dirty = false;
status.textContent = `${slug}.md`;
status.className = '';
btnSave.disabled = false;
await mountEditor(body);
}
/** @param {string} slug */
function requestPost(slug) {
if (dirty && !confirm('Discard unsaved changes?')) return;
send('getPost', { slug });
}
/**
* Uploads any unknown blob images, waits for them to land on disk,
* then cleans paths and sends savePost.
*/
async function savePost() {
if (!currentSlug) return;
btnSave.disabled = true;
// Rename file if slug was changed.
const desiredSlug = fmSlug.value.trim().toLowerCase();
if (desiredSlug && desiredSlug !== currentSlug) {
if (!isValidSlug(desiredSlug)) {
alert('Invalid slug: use only lowercase letters, numbers, and hyphens.');
fmSlug.value = currentSlug;
btnSave.disabled = false;
return;
}
const renamed = await new Promise(resolve => {
_pendingRenameResolve = resolve;
send('renamePost', { oldSlug: currentSlug, newSlug: desiredSlug });
setTimeout(() => {
if (_pendingRenameResolve) { _pendingRenameResolve = null; resolve(null); }
}, 5000);
});
if (!renamed) {
btnSave.disabled = false;
return; // error already shown by extension
}
}
const rawDoc = buildDocument();
console.log('[Blog Editor] savePost called, cleaning image paths...');
const blobs = [...rawDoc.matchAll(/!\[[^\]]*\]\((blob:[^)]+)\)/g)].map(m => m[1]);
const unknown = [...new Set(blobs.filter(b => !blobToRelative.has(b)))];
if (unknown.length > 0) {
status.textContent = `Uploading ${unknown.length} image(s)...`;
status.className = 'dirty';
await Promise.all(unknown.map(b => saveNewMediaAndWait(b)));
}
const cleanedDoc = cleanImagePaths(rawDoc);
const finalDoc = await uploadRemainingBlobs(cleanedDoc);
console.log('[Blog Editor] Final markdown blob count:', [...finalDoc.matchAll(/blob:/g)].length);
send('savePost', { slug: currentSlug, content: finalDoc });
}
// -- Events ---------------------------------------------------------------
[fmTitle, fmDate, fmAuthor, fmTags, fmSlug].forEach(el => el.addEventListener('input', markDirty));
btnSave.addEventListener('click', savePost);
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); savePost(); }
});
// -- Init -----------------------------------------------------------------
send('refreshPosts');
send('ready');
</script>
</body>
</html>

View File

@@ -0,0 +1,509 @@
'use strict';
const vscode = require('vscode');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const EXT_DIR = __dirname;
const DIST_DIR = path.join(EXT_DIR, 'dist');
const ROOT_DIR = path.resolve(EXT_DIR, '..');
/** @type {vscode.WebviewPanel | undefined} */
let panel;
/** @type {string | undefined} */
let activeBlogDir;
/** @type {string | undefined} */
let pendingOpenSlug;
/** @type {BlogSidebarProvider | undefined} */
let sidebarProvider;
/**
* @implements {vscode.TreeDataProvider<vscode.TreeItem>}
*/
class BlogSidebarProvider {
constructor() {
this._onDidChangeTreeData = new vscode.EventEmitter();
this.onDidChangeTreeData = this._onDidChangeTreeData.event;
}
refresh() {
this._onDidChangeTreeData.fire(undefined);
}
/**
* @returns {vscode.ProviderResult<vscode.TreeItem[]>}
*/
getChildren() {
if (!activeBlogDir || !fs.existsSync(activeBlogDir)) {
const openItem = new vscode.TreeItem('Open Blog Editor', vscode.TreeItemCollapsibleState.None);
openItem.tooltip = 'Open the Blog Editor panel';
openItem.command = {
command: 'blogEditor.open',
title: 'Open Blog Editor'
};
openItem.iconPath = new vscode.ThemeIcon('edit');
return [openItem];
}
const posts = fs.readdirSync(activeBlogDir)
.filter(f => f.endsWith('.md') && !f.startsWith('.'))
.map(f => {
const slug = path.basename(f, '.md');
const meta = parseFrontmatterFromFile(path.join(activeBlogDir, f));
return { slug, title: meta.title || slug, date: meta.date || '' };
})
.sort((a, b) => {
if (a.date && b.date) return b.date.localeCompare(a.date);
if (a.date) return -1;
if (b.date) return 1;
return a.slug.localeCompare(b.slug);
});
if (posts.length === 0) {
const empty = new vscode.TreeItem('No posts found', vscode.TreeItemCollapsibleState.None);
empty.iconPath = new vscode.ThemeIcon('circle-slash');
return [empty];
}
return posts.map(({ slug, title, date }) => new BlogPostItem(slug, title, date));
}
/**
* @param {vscode.TreeItem} element
* @returns {vscode.TreeItem}
*/
getTreeItem(element) {
return element;
}
}
/**
* Parses YAML-ish frontmatter from a markdown file.
* @param {string} filePath
* @returns {{ title?: string, date?: string }}
*/
function parseFrontmatterFromFile(filePath) {
try {
const content = fs.readFileSync(filePath, 'utf-8');
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!m) return {};
/** @type {Record<string, string>} */
const meta = {};
for (const line of m[1].split(/\r?\n/)) {
const i = line.indexOf(':');
if (i > 0) meta[line.slice(0, i).trim()] = line.slice(i + 1).trim().replace(/^["']|["']$/g, '');
}
return meta;
} catch { return {}; }
}
/**
* A tree item representing a single blog post.
* Stores the slug separately so context-menu commands can retrieve it
* even when the label displays the human-readable title.
*/
class BlogPostItem extends vscode.TreeItem {
/**
* @param {string} slug
* @param {string} title
* @param {string} date
*/
constructor(slug, title, date) {
super(title || slug, vscode.TreeItemCollapsibleState.None);
this.id = slug;
this.slug = slug;
this.description = date || '';
this.tooltip = `${slug}.md`;
this.contextValue = 'blogPost';
this.command = { command: 'blogEditor.openPost', title: 'Open Post', arguments: [slug] };
this.iconPath = new vscode.ThemeIcon('file');
}
}
/**
* Walks up from startDir to find node_modules containing a package.
* @param {string} pkg
* @param {string} startDir
* @returns {string | null}
*/
function resolveFromNearestNodeModules(pkg, startDir) {
let dir = startDir;
while (true) {
const candidate = path.join(dir, 'node_modules', pkg);
if (fs.existsSync(candidate)) return candidate;
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
/**
* @param {vscode.ExtensionContext} context
*/
async function activate(context) {
sidebarProvider = new BlogSidebarProvider();
context.subscriptions.push(
vscode.window.registerTreeDataProvider('blog-editor-sidebar', sidebarProvider),
vscode.commands.registerCommand('blogEditor.open', () => openEditor(context)),
vscode.commands.registerCommand('blogEditor.openPost', (slug) => openEditor(context, slug)),
vscode.commands.registerCommand('blogEditor.refreshPosts', () => sidebarProvider?.refresh()),
vscode.commands.registerCommand('blogEditor.newPost', () => newPost(context)),
vscode.commands.registerCommand('blogEditor.renamePost', (item) => renamePost(item))
);
}
/**
* Prompts the user for a slug and creates a new blank blog post.
* @param {vscode.ExtensionContext} context
*/
async function newPost(context) {
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!workspaceRoot) { vscode.window.showErrorMessage('No workspace folder open.'); return; }
const blogDir = path.join(workspaceRoot, 'website', 'data', 'blog');
const slug = await vscode.window.showInputBox({
prompt: 'Enter a slug for the new post (lowercase letters, numbers, hyphens)',
placeHolder: 'my-new-post',
validateInput: v => isValidSlug(v) ? null : 'Use only lowercase letters, numbers, and hyphens (e.g. my-post)'
});
if (!slug) return;
const filePath = path.join(blogDir, `${slug}.md`);
if (fs.existsSync(filePath)) {
vscode.window.showErrorMessage(`A post named "${slug}" already exists.`);
return;
}
const now = new Date().toISOString().slice(0, 10);
const starter = `---\ntitle: "${slug}"\ndate: "${now}"\ntags: []\n---\n\n`;
fs.mkdirSync(blogDir, { recursive: true });
fs.writeFileSync(filePath, starter, 'utf-8');
activeBlogDir = blogDir;
sidebarProvider?.refresh();
openEditor(context, slug);
}
/**
* Renames a blog post slug via right-click context menu on a sidebar tree item.
* @param {vscode.TreeItem} item
*/
async function renamePost(item) {
const oldSlug = item?.slug ?? item?.id ?? (typeof item?.label === 'string' ? item.label : undefined);
if (!oldSlug || typeof oldSlug !== 'string') { vscode.window.showErrorMessage('Could not determine post slug.'); return; }
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!workspaceRoot) { vscode.window.showErrorMessage('No workspace folder open.'); return; }
const blogDir = path.join(workspaceRoot, 'website', 'data', 'blog');
const oldPath = path.join(blogDir, `${oldSlug}.md`);
const newSlug = await vscode.window.showInputBox({
prompt: `Rename "${oldSlug}" to:`,
value: oldSlug,
validateInput: v => isValidSlug(v) ? null : 'Use only lowercase letters, numbers, and hyphens'
});
if (!newSlug || newSlug === oldSlug) return;
const newPath = path.join(blogDir, `${newSlug}.md`);
if (fs.existsSync(newPath)) { vscode.window.showErrorMessage(`"${newSlug}" already exists.`); return; }
fs.renameSync(oldPath, newPath);
sidebarProvider?.refresh();
// If this post is currently open in the editor, refresh it with the new slug.
if (panel) {
panel.webview.postMessage({ type: 'openSlug', slug: newSlug });
}
}
/**
* Builds the Milkdown JS + CSS bundles into dist/ if they don't exist yet.
* @returns {Promise<void>}
*/
async function ensureBundle() {
if (!fs.existsSync(DIST_DIR)) fs.mkdirSync(DIST_DIR, { recursive: true });
const milkdownPath = path.join(DIST_DIR, 'milkdown.js');
const commonCssPath = path.join(DIST_DIR, 'milkdown-common.css');
const themeCssPath = path.join(DIST_DIR, 'milkdown-theme.css');
// Prefer prebuilt assets in packaged VSIX to avoid requiring esbuild at runtime.
if (fs.existsSync(milkdownPath) && fs.existsSync(commonCssPath) && fs.existsSync(themeCssPath)) return;
await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: 'Blog Editor: Bundling Milkdown…' },
async () => {
const esbuildPkg = resolveFromNearestNodeModules('esbuild', EXT_DIR)
?? resolveFromNearestNodeModules('esbuild', process.cwd());
if (!esbuildPkg) {
throw new Error('Missing prebuilt dist assets and esbuild is unavailable. Rebuild the VSIX from the repo root so dist files are packaged.');
}
const esbuild = require(path.join(esbuildPkg, 'lib', 'main.js'));
const crepePkg = resolveFromNearestNodeModules('@milkdown/crepe', EXT_DIR)
?? resolveFromNearestNodeModules('@milkdown/crepe', process.cwd());
if (!crepePkg) {
throw new Error('Cannot find @milkdown/crepe for bundling. Install workspace dependencies or rebuild the VSIX with prebuilt dist.');
}
const rootDir = path.dirname(path.dirname(esbuildPkg));
const crepeDir = path.join(crepePkg, 'lib', 'theme');
const fontLoaders = { '.woff2': 'dataurl', '.woff': 'dataurl', '.ttf': 'dataurl', '.eot': 'dataurl', '.svg': 'dataurl' };
const [jsResult, cssCommon, cssTheme] = await Promise.all([
esbuild.build({
stdin: { contents: `export { Crepe, CrepeFeature } from '@milkdown/crepe';`, resolveDir: rootDir, loader: 'js' },
bundle: true, format: 'esm', platform: 'browser', target: 'es2022', minify: true, write: false,
}),
esbuild.build({
entryPoints: [path.join(crepeDir, 'common', 'style.css')],
bundle: true, write: false, loader: fontLoaders,
}),
esbuild.build({
entryPoints: [path.join(crepeDir, 'frame-dark', 'style.css')],
bundle: true, write: false, loader: fontLoaders,
}),
]);
fs.writeFileSync(path.join(DIST_DIR, 'milkdown.js'), jsResult.outputFiles[0].contents);
fs.writeFileSync(path.join(DIST_DIR, 'milkdown-common.css'), cssCommon.outputFiles[0].contents);
fs.writeFileSync(path.join(DIST_DIR, 'milkdown-theme.css'), cssTheme.outputFiles[0].contents);
}
);
}
/**
* @param {vscode.ExtensionContext} context
* @param {string} [initialSlug]
*/
async function openEditor(context, initialSlug) {
if (panel) {
panel.reveal();
if (initialSlug) {
panel.webview.postMessage({ type: 'openSlug', slug: initialSlug });
}
return;
}
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!workspaceRoot) { vscode.window.showErrorMessage('No workspace folder open.'); return; }
await ensureBundle();
const websiteDir = path.join(workspaceRoot, 'website');
const blogDir = path.join(websiteDir, 'data', 'blog');
activeBlogDir = blogDir;
sidebarProvider?.refresh();
if (initialSlug) pendingOpenSlug = initialSlug;
panel = vscode.window.createWebviewPanel(
'blogEditor', 'Blog Editor',
vscode.ViewColumn.One,
{
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [
vscode.Uri.file(DIST_DIR),
vscode.Uri.file(websiteDir),
],
}
);
panel.webview.html = buildHtml(panel.webview);
panel.webview.onDidReceiveMessage(msg => handleMessage(msg, blogDir, websiteDir), undefined, context.subscriptions);
panel.onDidDispose(() => {
panel = undefined;
pendingOpenSlug = undefined;
}, undefined, context.subscriptions);
}
/**
* Builds the webview HTML, replacing asset URIs and nonce.
* @param {vscode.Webview} webview
* @returns {string}
*/
function buildHtml(webview) {
const nonce = crypto.randomBytes(16).toString('hex');
const milkdownJs = webview.asWebviewUri(vscode.Uri.file(path.join(DIST_DIR, 'milkdown.js')));
const commonCss = webview.asWebviewUri(vscode.Uri.file(path.join(DIST_DIR, 'milkdown-common.css')));
const themeCss = webview.asWebviewUri(vscode.Uri.file(path.join(DIST_DIR, 'milkdown-theme.css')));
const csp = webview.cspSource;
const template = fs.readFileSync(path.join(EXT_DIR, 'editor.html'), 'utf-8');
return template
.replace(/\{\{NONCE\}\}/g, nonce)
.replace(/\{\{CSP_SOURCE\}\}/g, csp)
.replace('{{MILKDOWN_JS}}', milkdownJs.toString())
.replace('{{COMMON_CSS}}', commonCss.toString())
.replace('{{THEME_CSS}}', themeCss.toString());
}
/**
* 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, src, close) => {
if (/^https?:|^data:|^blob:/.test(src)) return match;
return `${open}${websiteBaseUri}/${src.replace(/^\//, '')}${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, rel, close) => `${open}${rel}${close}`
);
}
/**
* Handles messages from the webview.
* @param {{ type: string, slug?: string, content?: string, filename?: string, dataBase64?: string, blobUrl?: string }} msg
* @param {string} blogDir
* @param {string} websiteDir
*/
function handleMessage(msg, blogDir, websiteDir) {
const webview = panel?.webview;
if (!webview) return;
try {
const websiteBaseUri = webview.asWebviewUri(vscode.Uri.file(websiteDir)).toString();
switch (msg.type) {
case 'getPosts': {
const slugs = fs.readdirSync(blogDir)
.filter(f => f.endsWith('.md') && !f.startsWith('.'))
.map(f => path.basename(f, '.md'))
.sort();
webview.postMessage({ type: 'posts', slugs });
break;
}
case 'getPost': {
const filePath = safePostPath(blogDir, msg.slug);
if (!filePath) return;
const raw = fs.readFileSync(filePath, 'utf-8').replace(/!\[[^\]]*\]\(blob:[^)]+\)\r?\n?/g, '');
// Build imageMap: webviewUri → relativePath, so the browser can reverse blob URLs
const imageMap = {};
const imgPattern = /!\[[^\]]*\]\(([^)]+)\)/g;
let imgMatch;
while ((imgMatch = imgPattern.exec(raw)) !== null) {
const src = imgMatch[1];
if (/^https?:|^data:|^blob:/.test(src)) continue;
const absPath = path.join(websiteDir, src.replace(/^\//, ''));
if (fs.existsSync(absPath)) {
const uri = webview.asWebviewUri(vscode.Uri.file(absPath)).toString();
imageMap[uri] = src;
}
}
const content = injectWebviewImageUris(raw, websiteBaseUri);
webview.postMessage({ type: 'post', slug: msg.slug, content, imageMap });
break;
}
case 'savePost': {
const filePath = safePostPath(blogDir, msg.slug);
if (!filePath) return;
const content = revertWebviewImageUris(msg.content, websiteBaseUri);
fs.writeFileSync(filePath, content, 'utf-8');
webview.postMessage({ type: 'saved', slug: msg.slug });
break;
}
case 'saveMedia': {
const mediaDir = path.join(websiteDir, 'data', 'blog', 'media');
fs.mkdirSync(mediaDir, { recursive: true });
const safeName = (msg.filename || 'paste.png').replace(/[^a-z0-9._-]/gi, '_');
const filePath = path.join(mediaDir, safeName);
fs.writeFileSync(filePath, Buffer.from(msg.dataBase64, 'base64'));
const relativePath = `data/blog/media/${safeName}`;
const savedUri = webview.asWebviewUri(vscode.Uri.file(filePath)).toString();
webview.postMessage({ type: 'mediaSaved', blobUrl: msg.blobUrl, relativePath, webviewUri: savedUri });
break;
}
case 'newPost': {
if (!isValidSlug(msg.slug)) {
webview.postMessage({ type: 'error', message: 'Invalid slug.' });
return;
}
const filePath = path.join(blogDir, `${msg.slug}.md`);
if (fs.existsSync(filePath)) {
webview.postMessage({ type: 'error', message: `"${msg.slug}" already exists.` });
return;
}
fs.writeFileSync(filePath, msg.content, 'utf-8');
webview.postMessage({ type: 'created', slug: msg.slug });
sidebarProvider?.refresh();
break;
}
case 'renamePost': {
if (!isValidSlug(msg.newSlug)) {
webview.postMessage({ type: 'error', message: 'Invalid slug.' });
return;
}
const oldFilePath = safePostPath(blogDir, msg.oldSlug);
if (!oldFilePath) return;
const newFilePath = path.join(blogDir, `${msg.newSlug}.md`);
if (!newFilePath.startsWith(blogDir + path.sep)) return;
if (fs.existsSync(newFilePath)) {
webview.postMessage({ type: 'error', message: `"${msg.newSlug}" already exists.` });
return;
}
fs.renameSync(oldFilePath, newFilePath);
sidebarProvider?.refresh();
webview.postMessage({ type: 'renamed', oldSlug: msg.oldSlug, newSlug: msg.newSlug });
break;
}
case 'refreshPosts': {
sidebarProvider?.refresh();
break;
}
case 'ready': {
if (pendingOpenSlug) {
webview.postMessage({ type: 'openSlug', slug: pendingOpenSlug });
pendingOpenSlug = undefined;
}
break;
}
}
} catch (err) {
webview.postMessage({ type: 'error', message: String(err) });
}
}
/**
* Returns an absolute path to a post file, guarding against path traversal.
* @param {string} blogDir
* @param {string | undefined} slug
* @returns {string | null}
*/
function safePostPath(blogDir, slug) {
if (!isValidSlug(slug)) {
panel?.webview.postMessage({ type: 'error', message: 'Invalid slug.' });
return null;
}
const resolved = path.resolve(blogDir, `${slug}.md`);
if (!resolved.startsWith(blogDir + path.sep)) return null;
return resolved;
}
/**
* @param {string | undefined} slug
* @returns {boolean}
*/
function isValidSlug(slug) {
return typeof slug === 'string' && /^[a-z0-9][a-z0-9-]*$/.test(slug) && slug.length <= 100;
}
function deactivate() {}
module.exports = { activate, deactivate };

View File

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

View File

@@ -0,0 +1,8 @@
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" fill="none">
<rect x="8" y="6" width="48" height="52" rx="8" fill="#1f2937" stroke="#94a3b8" stroke-width="3"/>
<rect x="16" y="16" width="32" height="4" rx="2" fill="#38bdf8"/>
<rect x="16" y="26" width="24" height="4" rx="2" fill="#cbd5e1"/>
<rect x="16" y="36" width="20" height="4" rx="2" fill="#cbd5e1"/>
<path d="M42 42L52 52" stroke="#f59e0b" stroke-width="5" stroke-linecap="round"/>
<circle cx="38" cy="38" r="8" stroke="#f59e0b" stroke-width="4"/>
</svg>

After

Width:  |  Height:  |  Size: 560 B

15
tools/blogeditor/package-lock.json generated Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "blog-editor",
"version": "0.1.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "blog-editor",
"version": "0.1.3",
"engines": {
"vscode": "^1.80.0"
}
}
}
}

View File

@@ -0,0 +1,84 @@
{
"name": "blog-editor",
"displayName": "Blog Editor",
"description": "WYSIWYG Milkdown blog post editor for this workspace",
"version": "0.1.11",
"publisher": "local",
"engines": { "vscode": "^1.80.0" },
"categories": ["Other"],
"repository": { "type": "git", "url": "https://github.com/local/blog-editor" },
"activationEvents": [],
"main": "./extension.js",
"contributes": {
"commands": [
{
"command": "blogEditor.open",
"title": "Open Blog Editor",
"category": "Blog"
},
{
"command": "blogEditor.openPost",
"title": "Open Blog Post",
"category": "Blog"
},
{
"command": "blogEditor.refreshPosts",
"title": "Refresh Blog Posts",
"category": "Blog",
"icon": "$(refresh)"
},
{
"command": "blogEditor.newPost",
"title": "New Post",
"category": "Blog",
"icon": "$(add)"
},
{
"command": "blogEditor.renamePost",
"title": "Rename Post",
"category": "Blog"
}
],
"viewsContainers": {
"activitybar": [
{
"id": "blog-editor-container",
"title": "Blog Editor",
"icon": "media/blog-editor.svg"
}
]
},
"views": {
"blog-editor-container": [
{
"id": "blog-editor-sidebar",
"name": "Posts"
}
]
},
"menus": {
"view/title": [
{
"command": "blogEditor.newPost",
"when": "view == blog-editor-sidebar",
"group": "navigation@1"
},
{
"command": "blogEditor.refreshPosts",
"when": "view == blog-editor-sidebar",
"group": "navigation@2"
}
],
"view/item/context": [
{
"command": "blogEditor.renamePost",
"when": "view == blog-editor-sidebar && viewItem == blogPost",
"group": "inline"
}
]
}
},
"scripts": {
"package": "node ../build-dist.js && npx @vscode/vsce package --out blog-editor.vsix --no-dependencies"
}
}

View File

@@ -1,11 +1,10 @@
---
title: Markdown Formatting Test
date: 2026-04-20
date: 2026-09-11
author: Test Author
tags: test, formatting, markdown
---
# Heading 1
# Heading 1 test 123
## Heading 2
@@ -30,11 +29,15 @@ Here's a [regular link](https://example.com) and here's a [YouTube link](https:/
## Lists
Unordered list:
- First item
- Second item
- Third item
* First item
* Second item
* Third item
Ordered list:
1. First step
2. Second step
3. Third step
@@ -42,6 +45,7 @@ Ordered list:
## Code Blocks
Standard JavaScript:
```javascript
const hello = "world";
console.log(hello);
@@ -52,6 +56,7 @@ function test() {
```
Python with syntax highlighting:
```python
def hello_world():
print("Hello, World!")
@@ -59,7 +64,8 @@ def hello_world():
```
Code with max height (200px):
```javascript {maxHeight: 200}
```javascript
// This is a long code block that will scroll
const data = [1, 2, 3, 4, 5];
@@ -92,27 +98,32 @@ const obj = {
## Horizontal Rule
---
***
## Images
![Test Image](data/blog/media/test.png)
![1.00](data/blog/media/test.gif)
## Mixed Content
You can mix **bold**, *italic*, and `code` in the same paragraph. Here's a [link with **bold** text](https://example.com) too.
You can mix **bold**, *italic*, and `code` in the same paragraph. Here's a [link with](https://example.com) **[bold](https://example.com)** [text](https://example.com) too.
### Nested Lists
- Top level item
- Another top level
- Nested item (if supported)
- Another nested
* Top level itemsss
* Another top level
* Nested item (if supported)
* Another nested
1. Numbered item
2. Another numbered
- Mixed with bullets
- More bullets
* Mixed with bullets
* More bullets
## Special Characters

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -0,0 +1,15 @@
---
title: test d d d d
date: 2026-05-10
author: it me
---
asdffdsaasdffdsa
| <br /> | <br /> | <br /> | <br /> |
| :----- | :----- | :----- | -----: |
| <br /> | <br /> | <br /> | <br /> |
| <br /> | <br /> | <br /> | <br /> |
<br />
this is a test post, should't publish i guess, but who's reading lmao

View File

@@ -1,10 +1,9 @@
---
title: Welcome to My Blog
date: 2026-04-19
date: 2026-05-10
author: Max Litruv Boonzaayer
tags: welcome, meta, introduction
---
# Welcome to My Blog
This is my first blog post! I'm excited to share my thoughts and experiences with you.
@@ -15,15 +14,20 @@ This blog is built using a **custom node-based graph system** where each blog po
### Features
- 📝 Markdown support with YAML front matter
- 📅 Date-based organization
- 🏷️ Tag system for categorization
- 🎨 Interactive node-based visualization
- 📁 Media support in `data/blog/media/`
* 📝 Markdown support with YAML front matter
* 📅 Date-based organization
* 🏷️ Tag system for categorization
* 🎨 Interactive node-based visualization
* 📁 Media support in `data/blog/media/`
## Technical Details
The build system automatically:
1. Scans the `data/blog/` directory for `.md` files
2. Parses YAML front matter for metadata
3. Generates a `blogs.json` file

View File

@@ -9,7 +9,7 @@
"formatting",
"markdown"
],
"content": "\r\n# Heading 1\r\n\r\n## Heading 2\r\n\r\n### Heading 3\r\n\r\n#### Heading 4\r\n\r\n##### Heading 5\r\n\r\n###### Heading 6\r\n\r\n## Text Formatting\r\n\r\nThis is **bold text** and this is *italic text* and this is ***bold italic text***.\r\n\r\nHere's some `inline code` in a sentence.\r\n\r\n## Links\r\n\r\nHere's a [regular link](https://example.com) and here's a [YouTube link](https://youtube.com/watch?v=test).\r\n\r\n## Lists\r\n\r\nUnordered list:\r\n- First item\r\n- Second item\r\n- Third item\r\n\r\nOrdered list:\r\n1. First step\r\n2. Second step\r\n3. Third step\r\n\r\n## Code Blocks\r\n\r\nStandard JavaScript:\r\n```javascript\r\nconst hello = \"world\";\r\nconsole.log(hello);\r\n\r\nfunction test() {\r\n return true;\r\n}\r\n```\r\n\r\nPython with syntax highlighting:\r\n```python\r\ndef hello_world():\r\n print(\"Hello, World!\")\r\n return True\r\n```\r\n\r\nCode with max height (200px):\r\n```javascript {maxHeight: 200}\r\n// This is a long code block that will scroll\r\nconst data = [1, 2, 3, 4, 5];\r\n\r\nfunction processData(arr) {\r\n return arr.map(x => x * 2);\r\n}\r\n\r\nconsole.log(processData(data));\r\n\r\n// Adding more lines to demonstrate scrolling\r\nfor (let i = 0; i < 10; i++) {\r\n console.log(`Iteration ${i}`);\r\n}\r\n\r\n// Even more content\r\nconst obj = {\r\n name: \"Test\",\r\n value: 42,\r\n nested: {\r\n deep: true\r\n }\r\n};\r\n```\r\n\r\n## Blockquotes\r\n\r\n> This is a blockquote.\r\n> It can span multiple lines.\r\n> And continues here.\r\n\r\n## Horizontal Rule\r\n\r\n---\r\n\r\n## Images\r\n\r\n![Test Image](data/blog/media/test.png)\r\n\r\n## Mixed Content\r\n\r\nYou can mix **bold**, *italic*, and `code` in the same paragraph. Here's a [link with **bold** text](https://example.com) too.\r\n\r\n### Nested Lists\r\n\r\n- Top level item\r\n- Another top level\r\n - Nested item (if supported)\r\n - Another nested\r\n\r\n1. Numbered item\r\n2. Another numbered\r\n - Mixed with bullets\r\n - More bullets\r\n\r\n## Special Characters\r\n\r\nTesting special chars: < > & \" ' \r\n\r\n## Long Paragraph\r\n\r\nLorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\r\n\r\nDuis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\r\n"
"content": "\r\n# Heading 1\r\n\r\n## Heading 2\r\n\r\n### Heading 3\r\n\r\n#### Heading 4\r\n\r\n##### Heading 5\r\n\r\n###### Heading 6\r\n\r\n## Text Formatting\r\n\r\nThis is **bold text** and this is *italic text* and this is ***bold italic text***.\r\n\r\nHere's some `inline code` in a sentence.\r\n\r\n## Links\r\n\r\nHere's a [regular link](https://example.com) and here's a [YouTube link](https://youtube.com/watch?v=test).\r\n\r\n## Lists\r\n\r\nUnordered list:\r\n- First item\r\n- Second item\r\n- Third item\r\n\r\nOrdered list:\r\n1. First step\r\n2. Second step\r\n3. Third step\r\n\r\n## Code Blocks\r\n\r\nStandard JavaScript:\r\n```javascript\r\nconst hello = \"world\";\r\nconsole.log(hello);\r\n\r\nfunction test() {\r\n return true;\r\n}\r\n```\r\n\r\nPython with syntax highlighting:\r\n```python\r\ndef hello_world():\r\n print(\"Hello, World!\")\r\n return True\r\n```\r\n\r\nCode with max height (200px):\r\n```javascript {maxHeight: 200}\r\n// This is a long code block that will scroll\r\nconst data = [1, 2, 3, 4, 5];\r\n\r\nfunction processData(arr) {\r\n return arr.map(x => x * 2);\r\n}\r\n\r\nconsole.log(processData(data));\r\n\r\n// Adding more lines to demonstrate scrolling\r\nfor (let i = 0; i < 10; i++) {\r\n console.log(`Iteration ${i}`);\r\n}\r\n\r\n// Even more content\r\nconst obj = {\r\n name: \"Test\",\r\n value: 42,\r\n nested: {\r\n deep: true\r\n }\r\n};\r\n```\r\n\r\n## Blockquotes\r\n\r\n> This is a blockquote.\r\n> It can span multiple lines.\r\n> And continues here.\r\n\r\n## Horizontal Rule\r\n\r\n---\r\n\r\n## Images\r\n\r\n![Test Image](data/blog/media/test.gif)\r\n\r\n## Mixed Content\r\n\r\nYou can mix **bold**, *italic*, and `code` in the same paragraph. Here's a [link with **bold** text](https://example.com) too.\r\n\r\n### Nested Lists\r\n\r\n- Top level item\r\n- Another top level\r\n - Nested item (if supported)\r\n - Another nested\r\n\r\n1. Numbered item\r\n2. Another numbered\r\n - Mixed with bullets\r\n - More bullets\r\n\r\n## Special Characters\r\n\r\nTesting special chars: < > & \" ' \r\n\r\n## Long Paragraph\r\n\r\nLorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\r\n\r\nDuis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\r\n"
},
{
"slug": "welcome",

View File

@@ -29,17 +29,23 @@
<meta name="twitter:description" content="Personal site of Lit.ruv.wtf. Graph editor edition">
<meta name="twitter:image" content="https://lit.ruv.wtf/banner.webp">
<!-- Preconnect to external domains -->
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="preconnect" href="https://unpkg.com" crossorigin>
<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com">
<link rel="dns-prefetch" href="https://unpkg.com">
<link rel="stylesheet" href="styles/main.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-javascript.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-typescript.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-jsx.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-css.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-bash.min.js"></script>
<script src="https://unpkg.com/@lottiefiles/lottie-player@latest/dist/lottie-player.js"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-javascript.min.js"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-typescript.min.js"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-jsx.min.js"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-css.min.js"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-bash.min.js"></script>
<script defer src="https://unpkg.com/@lottiefiles/lottie-player@latest/dist/lottie-player.js"></script>
</head>
<body>
<div id="workspaceCanvas" class="workspace-canvas">

View File

@@ -202,6 +202,13 @@ export class ItemDragger {
if (!this.#enabled) return;
const target = /** @type {HTMLElement} */ (e.target);
if (target.closest(".pin-handle")) return;
// Allow text selection in blog/info node bodies
const nodeBody = target.closest(".node-body");
if (nodeBody && (node.type === 'info' || node.type === 'blog_post')) {
return; // Don't drag, allow text selection
}
e.stopPropagation();
const pointerWorld = this.#clientToWorkspace(e.clientX, e.clientY);

View File

@@ -91,7 +91,8 @@ export class MarkdownRenderer {
const langClass = lang ? ` language-${lang}` : "";
const styleAttr = maxHeight ? ` style="max-height: ${maxHeight}px; overflow-y: auto;"` : "";
return `<pre class="md-pre"${styleAttr}><code class="${langClass}">${highlightedCode}</code></pre>`;
const escapedCode = MarkdownRenderer.#escape(code);
return `<div class="md-code-block"><button class="md-copy-btn" data-code="${escapedCode}" title="Copy code">📋</button><pre class="md-pre"${styleAttr}><code class="${langClass}">${highlightedCode}</code></pre></div>`;
}
// Horizontal rule
@@ -106,6 +107,16 @@ export class MarkdownRenderer {
return `<h${level} class="md-h${level}">${MarkdownRenderer.#renderInline(headingMatch[2])}</h${level}>`;
}
// Standalone image
const imgBlockMatch = block.match(/^!\[([^\]]*)\]\(([^)]+)\)$/);
if (imgBlockMatch) {
const alt = MarkdownRenderer.#escape(imgBlockMatch[1]);
const src = MarkdownRenderer.#sanitizeUrl(imgBlockMatch[2]);
if (src) {
return `<figure class="md-figure"><img class="md-img" src="${src}" alt="${alt}" />${alt ? `<figcaption class="md-figcaption">${alt}</figcaption>` : ""}</figure>`;
}
}
// Blockquote
if (/^> /.test(block)) {
const inner = block.replace(/^> ?/gm, "");
@@ -114,20 +125,12 @@ export class MarkdownRenderer {
// Unordered list
if (/^[-*+] /.test(block)) {
const items = block.split("\n").filter(Boolean).map(line => {
const content = line.replace(/^[-*+]\s+/, "");
return `<li class="md-li">${MarkdownRenderer.#renderInline(content)}</li>`;
});
return `<ul class="md-ul">${items.join("")}</ul>`;
return MarkdownRenderer.#renderList(block, "ul");
}
// Ordered list
if (/^\d+\. /.test(block)) {
const items = block.split("\n").filter(Boolean).map(line => {
const content = line.replace(/^\d+\.\s+/, "");
return `<li class="md-li">${MarkdownRenderer.#renderInline(content)}</li>`;
});
return `<ol class="md-ol">${items.join("")}</ol>`;
return MarkdownRenderer.#renderList(block, "ol");
}
// Paragraph (handle single-line line breaks within block)
@@ -135,6 +138,57 @@ export class MarkdownRenderer {
return `<p class="md-p">${lines.join("<br />")}</p>`;
}
/**
* Renders nested lists (ul or ol) with indentation support.
* @param {string} block
* @param {"ul" | "ol"} listType
* @returns {string}
*/
static #renderList(block, listType) {
const lines = block.split("\n").filter(Boolean);
const items = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
const indent = line.match(/^(\s*)/)[1].length;
// Extract content
let content;
if (listType === "ul") {
content = line.replace(/^\s*[-*+]\s+/, "");
} else {
content = line.replace(/^\s*\d+\.\s+/, "");
}
// Look ahead for nested items (more indented)
let nestedBlock = "";
let j = i + 1;
while (j < lines.length) {
const nextLine = lines[j];
const nextIndent = nextLine.match(/^(\s*)/)[1].length;
if (nextIndent > indent) {
nestedBlock += (nestedBlock ? "\n" : "") + nextLine.slice(indent + 2); // Remove parent indent
j++;
} else {
break;
}
}
// Render item with nested list if present
let itemHtml = MarkdownRenderer.#renderInline(content);
if (nestedBlock) {
const nestedType = /^[-*+] /.test(nestedBlock.trim()) ? "ul" : "ol";
itemHtml += MarkdownRenderer.#renderList(nestedBlock, nestedType);
}
items.push(`<li class="md-li">${itemHtml}</li>`);
i = j;
}
const tag = listType === "ul" ? "ul" : "ol";
return `<${tag} class="md-${tag}">${items.join("")}</${tag}>`;
}
// ─── Inline-level ─────────────────────────────────────────────────────────
/**
@@ -159,6 +213,16 @@ export class MarkdownRenderer {
// Italic
out = out.replace(/\*(.+?)\*/g, "<em>$1</em>");
// Images ![alt](url) — must be matched before links
out = out.replace(
/!\[([^\]]*)\]\(([^)]+)\)/g,
(_, alt, url) => {
const safeUrl = MarkdownRenderer.#sanitizeUrl(url);
if (!safeUrl) return MarkdownRenderer.#escape(alt);
return `<img class="md-img md-img--inline" src="${safeUrl}" alt="${MarkdownRenderer.#escape(alt)}" />`;
}
);
// Links [text](url)
out = out.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
@@ -203,7 +267,10 @@ export class MarkdownRenderer {
*/
static #sanitizeUrl(url) {
const trimmed = url.trim();
// Allow absolute URLs, root-relative, hash links, relative-dot paths, and plain relative paths
if (/^(https?:\/\/|mailto:|\/|#|\.)/.test(trimmed)) return trimmed;
// Allow relative paths (e.g. data/blog/media/image.png) — no protocol = safe relative
if (/^[a-zA-Z0-9_\-][a-zA-Z0-9_.\-\/]*$/.test(trimmed)) return trimmed;
return null;
}
}

View File

@@ -481,6 +481,19 @@ export class WorkspaceNavigator {
/** @param {PointerEvent} e */
#onPointerDown(e) {
if (e.button !== 0 && e.button !== 2) return;
// Allow text selection in blog/info nodes
const nodeBody = e.target.closest('.node-body');
if (nodeBody) {
const blueprintNode = nodeBody.closest('.blueprint-node');
if (blueprintNode) {
const nodeType = blueprintNode.dataset.nodeType;
if (nodeType === 'info' || nodeType === 'blog_post') {
return; // Don't pan, allow text selection
}
}
}
this.#beginPan(e);
}

View File

@@ -100,6 +100,27 @@ export class BlogPostNode extends NodeBase {
const { MarkdownRenderer } = await import('../MarkdownRenderer.js');
contentDiv.innerHTML = MarkdownRenderer.render(post.content);
// Add copy button handlers
contentDiv.querySelectorAll('.md-copy-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const code = btn.getAttribute('data-code');
if (code) {
navigator.clipboard.writeText(code).then(() => {
const originalText = btn.textContent;
btn.textContent = '✓';
btn.classList.add('copied');
setTimeout(() => {
btn.textContent = originalText;
btn.classList.remove('copied');
}, 2000);
}).catch(err => {
console.error('Failed to copy:', err);
});
}
});
});
} catch (error) {
body.innerHTML = `<p style="color: #ff6b6b;">Error loading blog post: ${error.message}</p>`;
console.error('Failed to load blog post:', error);

View File

@@ -40,3 +40,5 @@ export class PrintNode extends NodeBase {
}
NodeRegistry.UCLASS_Register(PrintNode);

View File

@@ -1,281 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Personal Site</title>
<link rel="stylesheet" href="styles/main.css" />
</head>
<body>
<div id="workspaceCanvas" class="workspace-canvas">
<svg id="connectionLayer" class="connection-layer"></svg>
<div id="worldLayer" class="world-layer">
<div id="nodeLayer" class="node-layer"></div>
</div>
<div class="hud">
<button id="resetViewBtn" class="hud-btn" title="Reset View"></button>
</div>
<div id="debugPanel" class="debug-panel collapsed">
<div class="debug-header">
<div class="debug-title">Debug</div>
<button class="debug-toggle-btn" title="Toggle debug panel" aria-label="Toggle debug panel">
<svg class="debug-eye-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path class="eye-visible" d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle class="eye-visible" cx="12" cy="12" r="3"></circle>
<path class="eye-hidden" d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" style="display: none;"></path>
<line class="eye-hidden" x1="1" y1="1" x2="23" y2="23" style="display: none;"></line>
</svg>
</button>
</div>
<div class="debug-row">
<span class="debug-label">Zoom</span>
<span class="debug-value debug-zoom">1.000</span>
</div>
<div class="debug-row">
<span class="debug-label">DPR</span>
<span class="debug-value debug-dpr">1.0</span>
</div>
<div class="debug-row">
<span class="debug-label">Viewport</span>
<span class="debug-value debug-viewport">0 × 0</span>
</div>
<div class="debug-row">
<span class="debug-label">Center</span>
<span class="debug-value debug-center">0.0, 0.0</span>
</div>
<div class="debug-row">
<span class="debug-label">Viewbox</span>
<span class="debug-value debug-viewbox"></span>
</div>
<div class="debug-row">
<span class="debug-label">NodeAnchor</span>
<span class="debug-value debug-anchor"></span>
</div>
<div class="debug-divider"></div>
<label class="debug-row debug-check-row">
<span class="debug-label">Draggable</span>
<input class="debug-draggable-cb" type="checkbox" />
</label>
<label class="debug-row debug-check-row">
<span class="debug-label">WorldBoxes</span>
<input class="debug-worldbox-cb" type="checkbox" />
</label>
<label class="debug-row debug-check-row">
<span class="debug-label">Viewbox</span>
<input class="debug-viewbox-cb" type="checkbox" />
</label>
<div class="debug-divider"></div>
<button class="debug-copy-json-btn" title="Copy current graph as JSON">Copy JSON</button>
<button class="debug-show-properties-btn" title="Open properties panel">Show Properties</button>
<div class="debug-row debug-active-row" hidden>
<span class="debug-label debug-active-label"></span>
<span class="debug-value debug-active-pos">0.0, 0.0</span>
<button class="debug-copy-btn" title="Copy graph.json">Copy graph</button>
</div>
<div class="debug-divider" id="debugNodeEditorDivider" hidden></div>
<div id="debugNodeEditor" class="debug-node-editor" hidden>
<div class="debug-subtitle">Node Editor</div>
<div class="debug-row">
<span class="debug-label">ID</span>
<input class="debug-input debug-node-id" type="text" readonly />
</div>
<div class="debug-row">
<span class="debug-label">Type</span>
<input class="debug-input debug-node-type" type="text" readonly />
</div>
<div class="debug-row">
<span class="debug-label">Title</span>
<input class="debug-input debug-node-title" type="text" />
</div>
<div class="debug-row">
<span class="debug-label">X</span>
<input class="debug-input debug-node-x" type="number" step="1" />
</div>
<div class="debug-row">
<span class="debug-label">Y</span>
<input class="debug-input debug-node-y" type="number" step="1" />
</div>
<div class="debug-row">
<span class="debug-label">Width</span>
<input class="debug-input debug-node-width" type="number" step="1" />
</div>
<div class="debug-subtitle">Inputs</div>
<div id="debugInputsList" class="debug-pins-list"></div>
<button class="debug-add-btn debug-add-input-btn">+ Add Input</button>
<div class="debug-subtitle">Outputs</div>
<div id="debugOutputsList" class="debug-pins-list"></div>
<button class="debug-add-btn debug-add-output-btn">+ Add Output</button>
</div>
</div>
</div>
<!-- Properties Editor Panel (Right Side) -->
<div id="propertiesPanel" class="properties-panel collapsed">
<button class="properties-panel-tab" title="Open properties panel" aria-label="Open properties panel">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
</button>
<div class="properties-header">
<h2 class="properties-title">Properties</h2>
<button class="properties-toggle-btn" title="Close properties panel" aria-label="Close properties panel">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
</div>
<div class="properties-content">
<!-- Node Properties Section -->
<div id="nodePropertiesSection" class="properties-section" style="display: none;">
<div class="section-header">
<h3>Node</h3>
</div>
<div class="section-content">
<div class="prop-row">
<label class="prop-label">ID</label>
<input type="text" class="prop-input" id="propNodeId" readonly />
</div>
<div class="prop-row">
<label class="prop-label">Type</label>
<input type="text" class="prop-input" id="propNodeType" readonly />
</div>
<div class="prop-row">
<label class="prop-label">Title</label>
<input type="text" class="prop-input" id="propNodeTitle" />
</div>
<div class="prop-row">
<label class="prop-label">Position X</label>
<input type="number" class="prop-input" id="propNodeX" step="20" />
</div>
<div class="prop-row">
<label class="prop-label">Position Y</label>
<input type="number" class="prop-input" id="propNodeY" step="20" />
</div>
<div class="prop-row">
<label class="prop-label">Width</label>
<input type="number" class="prop-input" id="propNodeWidth" placeholder="Auto" />
</div>
</div>
</div>
<!-- Focus Options Section -->
<div id="focusOptionsSection" class="properties-section" style="display: none;">
<div class="section-header collapsible">
<h3>Focus Options</h3>
<button class="section-toggle"></button>
</div>
<div class="section-content">
<div class="prop-row">
<label class="prop-label">Duration (ms)</label>
<input type="number" class="prop-input" id="propFocusDuration" placeholder="550" />
</div>
<div class="prop-row">
<label class="prop-label">Anchor X</label>
<input type="number" class="prop-input" id="propFocusAnchorX" step="0.1" min="0" max="1" placeholder="0.5" />
</div>
<div class="prop-row">
<label class="prop-label">Anchor Y</label>
<input type="number" class="prop-input" id="propFocusAnchorY" step="0.1" min="0" max="1" placeholder="0.5" />
</div>
<div class="prop-divider"></div>
<h4 class="prop-subtitle">Default WorldBox</h4>
<div class="prop-row">
<label class="prop-label">Width</label>
<input type="number" class="prop-input" id="propWorldBoxWidth" placeholder="Auto" />
</div>
<div class="prop-row">
<label class="prop-label">Height</label>
<input type="number" class="prop-input" id="propWorldBoxHeight" placeholder="Auto" />
</div>
<div class="prop-divider"></div>
<h4 class="prop-subtitle">Responsive Breakpoints</h4>
<div id="breakpointsList" class="breakpoints-list"></div>
<button class="prop-btn-add" id="addBreakpointBtn">+ Add Breakpoint</button>
</div>
</div>
<!-- Graph Settings Section -->
<div id="graphSettingsSection" class="properties-section">
<div class="section-header collapsible">
<h3>Graph Settings</h3>
<button class="section-toggle"></button>
</div>
<div class="section-content">
<div class="prop-row">
<label class="prop-label">Draggable</label>
<input type="checkbox" class="prop-checkbox" id="propGraphDraggable" />
</div>
<div class="prop-row">
<label class="prop-label">Intro Duration (ms)</label>
<input type="number" class="prop-input" id="propIntroDuration" placeholder="700" />
</div>
<div class="prop-divider"></div>
<h4 class="prop-subtitle">Initial Viewport</h4>
<div class="prop-row">
<label class="prop-label">X</label>
<input type="number" class="prop-input" id="propInitViewX" />
</div>
<div class="prop-row">
<label class="prop-label">Y</label>
<input type="number" class="prop-input" id="propInitViewY" />
</div>
<div class="prop-row">
<label class="prop-label">Width</label>
<input type="number" class="prop-input" id="propInitViewWidth" />
</div>
<div class="prop-row">
<label class="prop-label">Height</label>
<input type="number" class="prop-input" id="propInitViewHeight" />
</div>
<div class="prop-divider"></div>
<button class="prop-btn-primary" id="applySettingsBtn">Apply Settings</button>
</div>
</div>
<!-- No Selection Message -->
<div id="noSelectionMessage" class="no-selection">
<p>Select a node to edit its properties</p>
</div>
</div>
</div>
<template id="nodeTemplate">
<article class="blueprint-node" draggable="false">
<header class="node-header">
<div class="node-title"></div>
</header>
<section class="node-io node-inputs"></section>
<section class="node-io node-outputs"></section>
</article>
</template>
<template id="pinTemplate">
<div class="pin" draggable="false">
<span class="pin-label"></span>
<span class="pin-handle" tabindex="-1"></span>
</div>
</template>
<!-- WorldBox Overlay Layer -->
<svg id="worldBoxLayer" class="worldbox-layer" style="display: none;"></svg>
<!-- Viewbox Overlay Layer -->
<svg id="viewboxLayer" class="viewbox-layer" style="display: none;"></svg>
<!-- JSON Editor Modal -->
<script type="module" src="scripts/main.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff