mirror of
https://github.com/litruv/lit.ruv.wtf.git
synced 2026-07-24 02:36:02 +10:00
blog post editor
This commit is contained in:
4
tools/blogeditor/.vscodeignore
Normal file
4
tools/blogeditor/.vscodeignore
Normal file
@@ -0,0 +1,4 @@
|
||||
# Exclude source/build artifacts — keep only what's needed in the VSIX
|
||||
build-dist.js
|
||||
*.vsix
|
||||
.gitignore
|
||||
BIN
tools/blogeditor/blog-editor-0.1.11.vsix
Normal file
BIN
tools/blogeditor/blog-editor-0.1.11.vsix
Normal file
Binary file not shown.
83
tools/blogeditor/build-dist.js
Normal file
83
tools/blogeditor/build-dist.js
Normal 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); });
|
||||
755
tools/blogeditor/editor.html
Normal file
755
tools/blogeditor/editor.html
Normal 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">💾</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'), ``);
|
||||
}
|
||||
}
|
||||
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>
|
||||
509
tools/blogeditor/extension.js
Normal file
509
tools/blogeditor/extension.js
Normal 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 };
|
||||
13
tools/blogeditor/launch.json
Normal file
13
tools/blogeditor/launch.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Blog Editor Extension",
|
||||
"type": "extensionHost",
|
||||
"request": "launch",
|
||||
"args": [
|
||||
"--extensionDevelopmentPath=${workspaceFolder}/.vscode"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
8
tools/blogeditor/media/blog-editor.svg
Normal file
8
tools/blogeditor/media/blog-editor.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" fill="none">
|
||||
<rect x="8" y="6" width="48" height="52" rx="8" fill="#1f2937" stroke="#94a3b8" stroke-width="3"/>
|
||||
<rect x="16" y="16" width="32" height="4" rx="2" fill="#38bdf8"/>
|
||||
<rect x="16" y="26" width="24" height="4" rx="2" fill="#cbd5e1"/>
|
||||
<rect x="16" y="36" width="20" height="4" rx="2" fill="#cbd5e1"/>
|
||||
<path d="M42 42L52 52" stroke="#f59e0b" stroke-width="5" stroke-linecap="round"/>
|
||||
<circle cx="38" cy="38" r="8" stroke="#f59e0b" stroke-width="4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 560 B |
15
tools/blogeditor/package-lock.json
generated
Normal file
15
tools/blogeditor/package-lock.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "blog-editor",
|
||||
"version": "0.1.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "blog-editor",
|
||||
"version": "0.1.3",
|
||||
"engines": {
|
||||
"vscode": "^1.80.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
84
tools/blogeditor/package.json
Normal file
84
tools/blogeditor/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user