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

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