Refactor code structure for improved readability and maintainability

This commit is contained in:
2026-05-12 04:43:25 +10:00
parent 655d16ea6b
commit b09a329d62
13 changed files with 3602 additions and 27 deletions

View File

@@ -4,10 +4,12 @@ const vscode = require('vscode');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const extensionManifest = require('./package.json');
const EXT_DIR = __dirname;
const DIST_DIR = path.join(EXT_DIR, 'dist');
const ROOT_DIR = path.resolve(EXT_DIR, '..');
const EXTENSION_VERSION = extensionManifest.version;
/** @type {vscode.WebviewPanel | undefined} */
let panel;
@@ -21,6 +23,26 @@ let pendingOpenSlug;
/** @type {BlogSidebarProvider | undefined} */
let sidebarProvider;
/** @type {vscode.OutputChannel | undefined} */
let output;
const DEFAULT_LM_STUDIO_BASE_URL = 'http://127.0.0.1:1234';
const LM_STUDIO_CHAT_ENDPOINTS = ['/v1/chat/completions'];
const LM_STUDIO_MODELS_ENDPOINTS = ['/api/v1/models', '/v1/models'];
const BLOG_TAGS_JSON_SCHEMA = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
title: 'Blog Tags',
type: 'array',
maxItems: 20,
uniqueItems: true,
items: {
type: 'string',
minLength: 1,
maxLength: 32,
pattern: '^[a-z0-9]+(-[a-z0-9]+)*$',
},
};
/**
* @implements {vscode.TreeDataProvider<vscode.TreeItem>}
*/
@@ -84,7 +106,7 @@ class BlogSidebarProvider {
/**
* Parses YAML-ish frontmatter from a markdown file.
* @param {string} filePath
* @returns {{ title?: string, date?: string }}
* @returns {{ title?: string, date?: string, author?: string, tags?: string }}
*/
function parseFrontmatterFromFile(filePath) {
try {
@@ -101,6 +123,405 @@ function parseFrontmatterFromFile(filePath) {
} catch { return {}; }
}
/**
* @param {string} value
* @returns {number}
*/
function parseDateToMs(value) {
if (!value) return 0;
const ts = Date.parse(value);
return Number.isFinite(ts) ? ts : 0;
}
/**
* Gets best previous author by most recent post date (fallback: file mtime).
* @param {string} blogDir
* @returns {string}
*/
function getPreviousAuthor(blogDir) {
if (!fs.existsSync(blogDir)) return '';
const entries = fs.readdirSync(blogDir)
.filter(f => f.endsWith('.md') && !f.startsWith('.'))
.map(file => {
const filePath = path.join(blogDir, file);
const meta = parseFrontmatterFromFile(filePath);
const mtimeMs = fs.statSync(filePath).mtimeMs;
return { author: (meta.author || '').trim(), dateMs: parseDateToMs(meta.date || ''), mtimeMs };
})
.filter(item => item.author)
.sort((a, b) => {
if (a.dateMs !== b.dateMs) return b.dateMs - a.dateMs;
return b.mtimeMs - a.mtimeMs;
});
return entries[0]?.author || '';
}
/**
* @param {string} raw
* @returns {string[]}
*/
function parseTagArray(raw) {
if (typeof raw !== 'string') {
if (raw && typeof raw === 'object' && Array.isArray(raw.tags)) {
return raw.tags
.map(v => String(v).trim().replace(/^#+/, '').toLowerCase())
.filter(Boolean);
}
return [];
}
const cleaned = raw
.replace(/^```(?:json)?/i, '')
.replace(/```$/i, '')
.trim();
if (cleaned.startsWith('{') && cleaned.endsWith('}')) {
try {
const parsed = JSON.parse(cleaned);
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.tags)) {
return parsed.tags
.map(v => String(v).trim().replace(/^#+/, '').toLowerCase())
.filter(Boolean);
}
} catch {
// Fall through to array and split parsing.
}
}
const start = cleaned.indexOf('[');
const end = cleaned.lastIndexOf(']');
if (start >= 0 && end > start) {
try {
const parsed = JSON.parse(cleaned.slice(start, end + 1));
if (Array.isArray(parsed)) {
return parsed
.map(v => String(v).trim().replace(/^#+/, '').toLowerCase())
.filter(Boolean);
}
} catch {
// Fall through to comma/newline split.
}
}
return cleaned
.split(/[\n,]/)
.map(v => v.replace(/^[\s\-•*#]+/, '').trim().toLowerCase())
.filter(Boolean);
}
/**
* @param {string[]} tags
* @returns {string[]}
*/
function normalizeGeneratedTags(tags) {
/** @type {string[]} */
const out = [];
const seen = new Set();
for (const tag of tags) {
const clean = tag
.replace(/^#+/, '')
.replace(/["'`]/g, '')
.trim()
.toLowerCase();
if (!clean || seen.has(clean)) continue;
seen.add(clean);
out.push(clean);
if (out.length >= 12) break;
}
return out;
}
const FALLBACK_TAG_RULES = [
{ tag: 'unreal-engine', patterns: [/\bunreal engine\b/i, /\bue\b/i] },
{ tag: 'game-development', patterns: [/\bgame dev\b/i, /\bgame development\b/i, /\bgamedev\b/i] },
{ tag: 'programming', patterns: [/\bprogramming\b/i, /\bdeveloper\b/i, /\bdevelopment\b/i] },
{ tag: '3d-art', patterns: [/\b3d art\b/i, /\b3d\b/i] },
{ tag: 'animation', patterns: [/\banimation\b/i, /\banimations\b/i] },
{ tag: 'systems-design', patterns: [/\bsystems design\b/i, /\binteraction systems\b/i, /\bsystems-heavy\b/i] },
{ tag: 'tools', patterns: [/\btools\b/i, /\btooling\b/i] },
{ tag: 'development-logs', patterns: [/\bdevelopment logs\b/i, /\bdev logs\b/i, /\blog\b/i] },
{ tag: 'embedded-devices', patterns: [/\bembedded devices\b/i, /\bembedded\b/i] },
{ tag: 'hardware', patterns: [/\bhardware\b/i, /\baudio gear\b/i] },
{ tag: 'ui-frameworks', patterns: [/\bui frameworks\b/i, /\bui framework\b/i] },
{ tag: 'modding', patterns: [/\bmodding\b/i, /\bmodding workflows\b/i] },
{ tag: 'prototypes', patterns: [/\bprototypes\b/i, /\bprototype\b/i] },
{ tag: 'experiments', patterns: [/\bexperiments\b/i, /\bexperiment\b/i] },
];
const FALLBACK_TAG_STOP_WORDS = new Set([
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'blog', 'both', 'but', 'by', 'for',
'from', 'here', 'into', 'its', 'just', 'make', 'more', 'onto', 'post', 'that',
'the', 'their', 'this', 'those', 'through', 'use', 'useful', 'using', 'welcome',
'with', 'worth', 'you', 'your',
]);
/**
* @param {{ title: string, body: string, tags: string }} payload
* @returns {string[]}
*/
function buildFallbackTags(payload) {
const sourceText = `${payload.title || ''}\n${payload.body || ''}`;
const fallback = normalizeGeneratedTags(parseTagArray(payload.tags || ''));
const seen = new Set(fallback);
for (const rule of FALLBACK_TAG_RULES) {
if (seen.has(rule.tag)) continue;
if (rule.patterns.some(pattern => pattern.test(sourceText))) {
fallback.push(rule.tag);
seen.add(rule.tag);
}
if (fallback.length >= 8) return fallback;
}
const titleTokens = (payload.title || '')
.toLowerCase()
.match(/[a-z0-9]+/g) || [];
for (const token of titleTokens) {
if (token.length < 4 || FALLBACK_TAG_STOP_WORDS.has(token) || seen.has(token)) continue;
fallback.push(token);
seen.add(token);
if (fallback.length >= 8) break;
}
return normalizeGeneratedTags(fallback);
}
/**
* @param {string} message
*/
function log(message) {
const ts = new Date().toISOString();
output?.appendLine(`[${ts}] ${message}`);
}
/**
* @param {string | undefined} urlOrBase
* @returns {string}
*/
function resolveLmStudioBaseUrl(urlOrBase) {
const raw = (urlOrBase || '').trim();
if (!raw) return DEFAULT_LM_STUDIO_BASE_URL;
try {
const parsed = new URL(raw);
parsed.search = '';
parsed.hash = '';
const pathname = parsed.pathname || '/';
const cut = pathname.search(/\/(api\/v1|v1)\b/i);
parsed.pathname = cut >= 0 ? pathname.slice(0, cut) || '/' : pathname;
return parsed.toString().replace(/\/$/, '');
} catch {
return DEFAULT_LM_STUDIO_BASE_URL;
}
}
/**
* @param {string} baseUrl
* @returns {string[]}
*/
function buildChatEndpointCandidates(baseUrl) {
return LM_STUDIO_CHAT_ENDPOINTS.map(p => `${baseUrl}${p}`);
}
/**
* @param {unknown} data
* @returns {string}
*/
function extractLmStudioMessageContent(data) {
if (!data || typeof data !== 'object') return '';
const obj = /** @type {Record<string, unknown>} */ (data);
const choices = Array.isArray(obj.choices) ? obj.choices : [];
for (const choice of choices) {
if (!choice || typeof choice !== 'object') continue;
const c = /** @type {Record<string, unknown>} */ (choice);
const message = c.message;
if (message && typeof message === 'object') {
const messageObj = /** @type {Record<string, unknown>} */ (message);
const content = messageObj.content;
if (typeof content === 'string' && content.trim()) return content;
const reasoningContent = messageObj.reasoning_content;
if (typeof reasoningContent === 'string' && reasoningContent.trim()) return reasoningContent;
}
if (typeof c.text === 'string' && c.text.trim()) return c.text;
}
if (typeof obj.output_text === 'string' && obj.output_text.trim()) return obj.output_text;
if (typeof obj.content === 'string' && obj.content.trim()) return obj.content;
return '';
}
/**
* @param {string} baseUrl
* @param {AbortSignal} signal
* @returns {Promise<string>}
*/
async function detectLmStudioModel(baseUrl, signal) {
for (const endpointPath of LM_STUDIO_MODELS_ENDPOINTS) {
const endpoint = `${baseUrl}${endpointPath}`;
try {
log(`AI tags: probing models endpoint ${endpoint}`);
const res = await fetch(endpoint, { method: 'GET', signal });
log(`AI tags: models endpoint status ${res.status} (${endpoint})`);
if (!res.ok) continue;
/** @type {{ data?: Array<{ id?: string, state?: string, loaded?: boolean }> }} */
const data = await res.json();
const models = Array.isArray(data?.data) ? data.data : [];
const loaded = models.find(m => m && typeof m.id === 'string' && (m.loaded === true || String(m.state || '').toLowerCase() === 'loaded'));
if (loaded?.id) {
log(`AI tags: selected loaded model ${loaded.id}`);
return loaded.id;
}
const first = models.find(m => m && typeof m.id === 'string');
if (first?.id) {
log(`AI tags: selected first model ${first.id}`);
return first.id;
}
} catch {
// Try next endpoint variant.
}
}
log('AI tags: could not detect model from API, using fallback local-model');
return 'local-model';
}
/**
* @param {string} endpoint
* @param {Record<string, unknown>} body
* @param {AbortSignal} signal
* @returns {Promise<Response>}
*/
async function postLmStudio(endpoint, body, signal) {
const payload = { ...body, stream: false };
return fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal,
body: JSON.stringify(payload),
});
}
/**
* Generates tags from LM Studio OpenAI-compatible endpoint.
* @param {{ title: string, body: string, tags: string }} payload
* @returns {Promise<string[]>}
*/
async function generateTagsWithLmStudio(payload) {
const configuredUrl = process.env.BLOG_EDITOR_LM_STUDIO_URL;
const baseUrl = resolveLmStudioBaseUrl(configuredUrl);
const endpoints = buildChatEndpointCandidates(baseUrl);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 90000);
const prompt = [
'Generate 3 to 8 relevant blog tags.',
'Return at least 1 tag even if the post is sparse.',
'Keep any existing tag if it is still relevant.',
'Prefer concrete topical tags over generic filler.',
`Title: ${payload.title || '(untitled)'}`,
`Existing tags: ${payload.tags || '(none)'}`,
'Body:',
(payload.body || '').slice(0, 6000),
].join('\n\n');
try {
log(`AI tags: request started (version=${EXTENSION_VERSION}, title=${payload.title ? 'yes' : 'no'}, bodyChars=${payload.body.length}, existingTags=${payload.tags ? 'yes' : 'no'})`);
log(`AI tags: base URL ${baseUrl}`);
log(`AI tags: title preview ${JSON.stringify((payload.title || '').slice(0, 120))}`);
log(`AI tags: existing tags preview ${JSON.stringify((payload.tags || '').slice(0, 120))}`);
log(`AI tags: body preview ${JSON.stringify((payload.body || '').slice(0, 280))}`);
const modelId = await detectLmStudioModel(baseUrl, controller.signal);
log(`AI tags: using model ${modelId}`);
let lastError = '';
for (const endpoint of endpoints) {
const requestBody = {
model: modelId,
temperature: 0.2,
max_tokens: 256,
messages: [
{
role: 'system',
content: 'Return only a JSON array that matches the provided schema. Do not include prose, markdown, commentary, or reasoning. The array must contain at least one relevant tag.',
},
{
role: 'user',
content: prompt,
},
],
response_format: {
type: 'json_schema',
json_schema: {
name: 'blog_tags',
strict: true,
schema: BLOG_TAGS_JSON_SCHEMA,
},
},
};
let res = await postLmStudio(endpoint, requestBody, controller.signal);
log(`AI tags: chat attempt ${endpoint} -> ${res.status}`);
let textOnError = '';
if (!res.ok) textOnError = await res.text();
// Structured output is required here. If an endpoint does not support
// response_format/json_schema, skip it and try the next endpoint
// instead of falling back to unstructured text generation.
if (!res.ok && (res.status === 400 || res.status === 422)) {
const unsupported = /response_format|json_schema|unsupported|unknown/i.test(textOnError);
if (unsupported) {
lastError = `Endpoint ${endpoint} does not support structured output.`;
log(`AI tags: ${lastError}`);
continue;
}
}
if (!res.ok) {
const text = textOnError || await res.text();
const maybeWrongEndpoint = res.status === 404 || res.status === 405 || /not\s*found/i.test(text);
if (maybeWrongEndpoint) {
lastError = `Endpoint ${endpoint} unavailable (${res.status}).`;
log(`AI tags: ${lastError}`);
continue;
}
lastError = `LM Studio request failed (${res.status}) on ${endpoint}: ${text || 'no body'}`;
log(`AI tags: ${lastError}`);
continue;
}
/** @type {unknown} */
const data = await res.json();
const content = extractLmStudioMessageContent(data);
const parsed = parseTagArray(content);
const tags = normalizeGeneratedTags(parsed);
if (tags.length === 0) {
const fallbackTags = buildFallbackTags(payload);
if (fallbackTags.length > 0) {
log(`AI tags: LM Studio returned empty structured output, falling back to ${fallbackTags.length} derived tag(s)`);
log(`AI tags: fallback tags ${JSON.stringify(fallbackTags)}`);
return fallbackTags;
}
lastError = `LM Studio returned no usable tags from ${endpoint}.`;
log(`AI tags: ${lastError}`);
continue;
}
log(`AI tags: success via ${endpoint}, generated ${tags.length} tags`);
return tags;
}
throw new Error(lastError || `No LM Studio chat endpoint succeeded. Tried: ${endpoints.join(', ')}`);
} catch (err) {
if (err && typeof err === 'object' && 'name' in err && err.name === 'AbortError') {
log('AI tags: request timed out after 90s');
throw new Error('LM Studio request timed out after 90s.');
}
log(`AI tags: exception ${String(err)}`);
throw err;
} finally {
clearTimeout(timeout);
log('AI tags: request finished');
}
}
/**
* A tree item representing a single blog post.
* Stores the slug separately so context-menu commands can retrieve it
@@ -145,6 +566,10 @@ function resolveFromNearestNodeModules(pkg, startDir) {
* @param {vscode.ExtensionContext} context
*/
async function activate(context) {
output = vscode.window.createOutputChannel('Blog Editor');
context.subscriptions.push(output);
log(`Blog Editor activated (version ${EXTENSION_VERSION})`);
sidebarProvider = new BlogSidebarProvider();
context.subscriptions.push(
vscode.window.registerTreeDataProvider('blog-editor-sidebar', sidebarProvider),
@@ -180,7 +605,9 @@ async function newPost(context) {
}
const now = new Date().toISOString().slice(0, 10);
const starter = `---\ntitle: "${slug}"\ndate: "${now}"\ntags: []\n---\n\n`;
const author = getPreviousAuthor(blogDir);
const authorLine = author ? `\nauthor: "${author.replace(/"/g, '\\"')}"` : '';
const starter = `---\ntitle: "${slug}"\ndate: "${now}"${authorLine}\ntags: ""\n---\n\n`;
fs.mkdirSync(blogDir, { recursive: true });
fs.writeFileSync(filePath, starter, 'utf-8');
activeBlogDir = blogDir;
@@ -231,9 +658,10 @@ async function ensureBundle() {
const milkdownPath = path.join(DIST_DIR, 'milkdown.js');
const commonCssPath = path.join(DIST_DIR, 'milkdown-common.css');
const themeCssPath = path.join(DIST_DIR, 'milkdown-theme.css');
const spellcheckPath = path.join(DIST_DIR, 'spellcheck.js');
// Prefer prebuilt assets in packaged VSIX to avoid requiring esbuild at runtime.
if (fs.existsSync(milkdownPath) && fs.existsSync(commonCssPath) && fs.existsSync(themeCssPath)) return;
if (fs.existsSync(milkdownPath) && fs.existsSync(commonCssPath) && fs.existsSync(themeCssPath) && fs.existsSync(spellcheckPath)) return;
await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: 'Blog Editor: Bundling Milkdown…' },
@@ -253,7 +681,7 @@ async function ensureBundle() {
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([
const [jsResult, cssCommon, cssTheme, spellcheckResult] = await Promise.all([
esbuild.build({
stdin: { contents: `export { Crepe, CrepeFeature } from '@milkdown/crepe';`, resolveDir: rootDir, loader: 'js' },
bundle: true, format: 'esm', platform: 'browser', target: 'es2022', minify: true, write: false,
@@ -266,11 +694,22 @@ async function ensureBundle() {
entryPoints: [path.join(crepeDir, 'frame-dark', 'style.css')],
bundle: true, write: false, loader: fontLoaders,
}),
esbuild.build({
entryPoints: [path.join(EXT_DIR, 'spellcheck.js')],
bundle: true,
format: 'esm',
platform: 'browser',
target: 'es2022',
minify: true,
write: false,
loader: { '.aff': 'text', '.dic': 'text' },
}),
]);
fs.writeFileSync(path.join(DIST_DIR, 'milkdown.js'), jsResult.outputFiles[0].contents);
fs.writeFileSync(path.join(DIST_DIR, 'milkdown-common.css'), cssCommon.outputFiles[0].contents);
fs.writeFileSync(path.join(DIST_DIR, 'milkdown-theme.css'), cssTheme.outputFiles[0].contents);
fs.writeFileSync(path.join(DIST_DIR, 'spellcheck.js'), spellcheckResult.outputFiles[0].contents);
}
);
}
@@ -330,6 +769,7 @@ async function openEditor(context, initialSlug) {
function buildHtml(webview) {
const nonce = crypto.randomBytes(16).toString('hex');
const milkdownJs = webview.asWebviewUri(vscode.Uri.file(path.join(DIST_DIR, 'milkdown.js')));
const spellcheckJs = webview.asWebviewUri(vscode.Uri.file(path.join(DIST_DIR, 'spellcheck.js')));
const commonCss = webview.asWebviewUri(vscode.Uri.file(path.join(DIST_DIR, 'milkdown-common.css')));
const themeCss = webview.asWebviewUri(vscode.Uri.file(path.join(DIST_DIR, 'milkdown-theme.css')));
const csp = webview.cspSource;
@@ -339,6 +779,7 @@ function buildHtml(webview) {
.replace(/\{\{NONCE\}\}/g, nonce)
.replace(/\{\{CSP_SOURCE\}\}/g, csp)
.replace('{{MILKDOWN_JS}}', milkdownJs.toString())
.replace('{{SPELLCHECK_JS}}', spellcheckJs.toString())
.replace('{{COMMON_CSS}}', commonCss.toString())
.replace('{{THEME_CSS}}', themeCss.toString());
}
@@ -391,7 +832,7 @@ function revertWebviewImageUris(content, websiteBaseUri) {
/**
* Handles messages from the webview.
* @param {{ type: string, slug?: string, content?: string, filename?: string, dataBase64?: string, blobUrl?: string }} msg
* @param {{ type: string, slug?: string, oldSlug?: string, newSlug?: string, content?: string, filename?: string, dataBase64?: string, blobUrl?: string, title?: string, body?: string, tags?: string }} msg
* @param {string} blogDir
* @param {string} websiteDir
*/
@@ -486,6 +927,28 @@ function handleMessage(msg, blogDir, websiteDir) {
sidebarProvider?.refresh();
break;
}
case 'generateTags': {
const title = (msg.title || '').trim();
const body = (msg.body || '').trim();
const tags = (msg.tags || '').trim();
log('AI tags: webview requested generation');
if (!title && !body) {
log('AI tags: rejected request - missing title/body');
webview.postMessage({ type: 'error', message: 'Need title or body for tag generation.' });
return;
}
webview.postMessage({ type: 'aiTagsStarted' });
generateTagsWithLmStudio({ title, body, tags })
.then(generated => {
log('AI tags: sending generated tags to webview');
webview.postMessage({ type: 'aiTagsGenerated', tags: generated });
})
.catch(err => {
log(`AI tags: generation failed ${String(err)}`);
webview.postMessage({ type: 'error', message: `AI tag generation failed: ${String(err)}` });
});
break;
}
case 'ready': {
if (pendingOpenSlug) {
webview.postMessage({ type: 'openSlug', slug: pendingOpenSlug });