mirror of
https://github.com/litruv/lit.ruv.wtf.git
synced 2026-07-24 02:36:02 +10:00
Refactor code structure for improved readability and maintainability
This commit is contained in:
49
package-lock.json
generated
49
package-lock.json
generated
@@ -15,7 +15,9 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@milkdown/crepe": "^7.20.0",
|
"@milkdown/crepe": "^7.20.0",
|
||||||
"clean-css-cli": "^5.6.3",
|
"clean-css-cli": "^5.6.3",
|
||||||
"esbuild": "^0.28.0"
|
"dictionary-en-us": "^2.2.1",
|
||||||
|
"esbuild": "^0.28.0",
|
||||||
|
"nspell": "^2.1.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-string-parser": {
|
"node_modules/@babel/helper-string-parser": {
|
||||||
@@ -2493,6 +2495,17 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dictionary-en-us": {
|
||||||
|
"version": "2.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dictionary-en-us/-/dictionary-en-us-2.2.1.tgz",
|
||||||
|
"integrity": "sha512-Z8mycV0ywTfjbUTi0JZfQHqBZhu4CYFtpf7KluSGpt3xHpFlal2S/hiK50tlPynOtR5K3pG5wCradnz1yxwOHA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "(MIT AND BSD)",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.4.2",
|
"version": "3.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz",
|
||||||
@@ -2766,6 +2779,30 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-buffer": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-extglob": {
|
"node_modules/is-extglob": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
@@ -3830,6 +3867,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nspell": {
|
||||||
|
"version": "2.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/nspell/-/nspell-2.1.5.tgz",
|
||||||
|
"integrity": "sha512-PSStyugKMiD9mHmqI/CR5xXrSIGejUXPlo88FBRq5Og1kO5QwQ5Ilu8D8O5I/SHpoS+mibpw6uKA8rd3vXd2Sg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-buffer": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/omggif": {
|
"node_modules/omggif": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz",
|
||||||
|
|||||||
@@ -32,6 +32,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@milkdown/crepe": "^7.20.0",
|
"@milkdown/crepe": "^7.20.0",
|
||||||
"clean-css-cli": "^5.6.3",
|
"clean-css-cli": "^5.6.3",
|
||||||
"esbuild": "^0.28.0"
|
"dictionary-en-us": "^2.2.1",
|
||||||
|
"esbuild": "^0.28.0",
|
||||||
|
"nspell": "^2.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ class StaticBlogGenerator {
|
|||||||
' </header>',
|
' </header>',
|
||||||
'<div class="md-hr" role="separator"><span class="md-hr-line"></span><span class="md-hr-arrow"></span></div>',
|
'<div class="md-hr" role="separator"><span class="md-hr-line"></span><span class="md-hr-arrow"></span></div>',
|
||||||
' <section class="blog-post-content" data-blog-post-content></section>',
|
' <section class="blog-post-content" data-blog-post-content></section>',
|
||||||
|
' <section class="blog-comments" data-blog-comments data-matrix-homeserver="https://chat.ruv.wtf" data-matrix-room="#general:chat.ruv.wtf"></section>',
|
||||||
` <script id="blogPostMarkdown" type="application/json">${markdownJson}</script>`,
|
` <script id="blogPostMarkdown" type="application/json">${markdownJson}</script>`,
|
||||||
'</article>',
|
'</article>',
|
||||||
nextPeek,
|
nextPeek,
|
||||||
|
|||||||
BIN
tools/blogeditor/blog-editor.vsix
Normal file
BIN
tools/blogeditor/blog-editor.vsix
Normal file
Binary file not shown.
@@ -50,6 +50,11 @@ const FONT_LOADERS = {
|
|||||||
'.svg': 'dataurl',
|
'.svg': 'dataurl',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TEXT_LOADERS = {
|
||||||
|
'.aff': 'text',
|
||||||
|
'.dic': 'text',
|
||||||
|
};
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
fs.mkdirSync(DIST_DIR, { recursive: true });
|
fs.mkdirSync(DIST_DIR, { recursive: true });
|
||||||
|
|
||||||
@@ -77,6 +82,20 @@ async function main() {
|
|||||||
fs.writeFileSync(path.join(DIST_DIR, 'milkdown-theme.css'), cssTheme.outputFiles[0].contents);
|
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(`✓ milkdown-theme.css ${(cssTheme.outputFiles[0].contents.byteLength / 1024).toFixed(0)} KB`);
|
||||||
|
|
||||||
|
console.log('⏳ Bundling spellcheck JS…');
|
||||||
|
const spellcheckJs = await esbuild.build({
|
||||||
|
entryPoints: [path.join(ROOT, 'spellcheck.js')],
|
||||||
|
bundle: true,
|
||||||
|
format: 'esm',
|
||||||
|
platform: 'browser',
|
||||||
|
target: 'es2022',
|
||||||
|
minify: true,
|
||||||
|
write: false,
|
||||||
|
loader: TEXT_LOADERS,
|
||||||
|
});
|
||||||
|
fs.writeFileSync(path.join(DIST_DIR, 'spellcheck.js'), spellcheckJs.outputFiles[0].contents);
|
||||||
|
console.log(`✓ spellcheck.js ${(spellcheckJs.outputFiles[0].contents.byteLength / 1024).toFixed(0)} KB`);
|
||||||
|
|
||||||
console.log('\n✓ dist/ ready — run: npx @vscode/vsce package --out blog-editor.vsix --no-dependencies');
|
console.log('\n✓ dist/ ready — run: npx @vscode/vsce package --out blog-editor.vsix --no-dependencies');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
44
tools/blogeditor/bump-version.js
Normal file
44
tools/blogeditor/bump-version.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const PACKAGE_JSON_PATH = path.join(__dirname, 'package.json');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a semantic version string and bumps the patch number.
|
||||||
|
* @param {string} version
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function bumpPatchVersion(version) {
|
||||||
|
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`Invalid version format: "${version}". Expected x.y.z`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const major = Number(match[1]);
|
||||||
|
const minor = Number(match[2]);
|
||||||
|
const patch = Number(match[3]) + 1;
|
||||||
|
return `${major}.${minor}.${patch}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads package.json, increments patch version, and writes the updated file.
|
||||||
|
*/
|
||||||
|
function main() {
|
||||||
|
const raw = fs.readFileSync(PACKAGE_JSON_PATH, 'utf8');
|
||||||
|
const pkg = JSON.parse(raw);
|
||||||
|
|
||||||
|
if (typeof pkg.version !== 'string') {
|
||||||
|
throw new Error('package.json is missing a valid string version field.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = pkg.version;
|
||||||
|
const next = bumpPatchVersion(previous);
|
||||||
|
pkg.version = next;
|
||||||
|
|
||||||
|
fs.writeFileSync(PACKAGE_JSON_PATH, `${JSON.stringify(pkg, null, 4)}\n`, 'utf8');
|
||||||
|
console.log(`[blog-editor] version bumped: ${previous} -> ${next}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -4,10 +4,12 @@ const vscode = require('vscode');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
const extensionManifest = require('./package.json');
|
||||||
|
|
||||||
const EXT_DIR = __dirname;
|
const EXT_DIR = __dirname;
|
||||||
const DIST_DIR = path.join(EXT_DIR, 'dist');
|
const DIST_DIR = path.join(EXT_DIR, 'dist');
|
||||||
const ROOT_DIR = path.resolve(EXT_DIR, '..');
|
const ROOT_DIR = path.resolve(EXT_DIR, '..');
|
||||||
|
const EXTENSION_VERSION = extensionManifest.version;
|
||||||
|
|
||||||
/** @type {vscode.WebviewPanel | undefined} */
|
/** @type {vscode.WebviewPanel | undefined} */
|
||||||
let panel;
|
let panel;
|
||||||
@@ -21,6 +23,26 @@ let pendingOpenSlug;
|
|||||||
/** @type {BlogSidebarProvider | undefined} */
|
/** @type {BlogSidebarProvider | undefined} */
|
||||||
let sidebarProvider;
|
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>}
|
* @implements {vscode.TreeDataProvider<vscode.TreeItem>}
|
||||||
*/
|
*/
|
||||||
@@ -84,7 +106,7 @@ class BlogSidebarProvider {
|
|||||||
/**
|
/**
|
||||||
* Parses YAML-ish frontmatter from a markdown file.
|
* Parses YAML-ish frontmatter from a markdown file.
|
||||||
* @param {string} filePath
|
* @param {string} filePath
|
||||||
* @returns {{ title?: string, date?: string }}
|
* @returns {{ title?: string, date?: string, author?: string, tags?: string }}
|
||||||
*/
|
*/
|
||||||
function parseFrontmatterFromFile(filePath) {
|
function parseFrontmatterFromFile(filePath) {
|
||||||
try {
|
try {
|
||||||
@@ -101,6 +123,405 @@ function parseFrontmatterFromFile(filePath) {
|
|||||||
} catch { return {}; }
|
} 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.
|
* A tree item representing a single blog post.
|
||||||
* Stores the slug separately so context-menu commands can retrieve it
|
* Stores the slug separately so context-menu commands can retrieve it
|
||||||
@@ -145,6 +566,10 @@ function resolveFromNearestNodeModules(pkg, startDir) {
|
|||||||
* @param {vscode.ExtensionContext} context
|
* @param {vscode.ExtensionContext} context
|
||||||
*/
|
*/
|
||||||
async function activate(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();
|
sidebarProvider = new BlogSidebarProvider();
|
||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
vscode.window.registerTreeDataProvider('blog-editor-sidebar', sidebarProvider),
|
vscode.window.registerTreeDataProvider('blog-editor-sidebar', sidebarProvider),
|
||||||
@@ -180,7 +605,9 @@ async function newPost(context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString().slice(0, 10);
|
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.mkdirSync(blogDir, { recursive: true });
|
||||||
fs.writeFileSync(filePath, starter, 'utf-8');
|
fs.writeFileSync(filePath, starter, 'utf-8');
|
||||||
activeBlogDir = blogDir;
|
activeBlogDir = blogDir;
|
||||||
@@ -231,9 +658,10 @@ async function ensureBundle() {
|
|||||||
const milkdownPath = path.join(DIST_DIR, 'milkdown.js');
|
const milkdownPath = path.join(DIST_DIR, 'milkdown.js');
|
||||||
const commonCssPath = path.join(DIST_DIR, 'milkdown-common.css');
|
const commonCssPath = path.join(DIST_DIR, 'milkdown-common.css');
|
||||||
const themeCssPath = path.join(DIST_DIR, 'milkdown-theme.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.
|
// 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(
|
await vscode.window.withProgress(
|
||||||
{ location: vscode.ProgressLocation.Notification, title: 'Blog Editor: Bundling Milkdown…' },
|
{ location: vscode.ProgressLocation.Notification, title: 'Blog Editor: Bundling Milkdown…' },
|
||||||
@@ -253,7 +681,7 @@ async function ensureBundle() {
|
|||||||
const crepeDir = path.join(crepePkg, 'lib', 'theme');
|
const crepeDir = path.join(crepePkg, 'lib', 'theme');
|
||||||
const fontLoaders = { '.woff2': 'dataurl', '.woff': 'dataurl', '.ttf': 'dataurl', '.eot': 'dataurl', '.svg': 'dataurl' };
|
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({
|
esbuild.build({
|
||||||
stdin: { contents: `export { Crepe, CrepeFeature } from '@milkdown/crepe';`, resolveDir: rootDir, loader: 'js' },
|
stdin: { contents: `export { Crepe, CrepeFeature } from '@milkdown/crepe';`, resolveDir: rootDir, loader: 'js' },
|
||||||
bundle: true, format: 'esm', platform: 'browser', target: 'es2022', minify: true, write: false,
|
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')],
|
entryPoints: [path.join(crepeDir, 'frame-dark', 'style.css')],
|
||||||
bundle: true, write: false, loader: fontLoaders,
|
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.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-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, '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) {
|
function buildHtml(webview) {
|
||||||
const nonce = crypto.randomBytes(16).toString('hex');
|
const nonce = crypto.randomBytes(16).toString('hex');
|
||||||
const milkdownJs = webview.asWebviewUri(vscode.Uri.file(path.join(DIST_DIR, 'milkdown.js')));
|
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 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 themeCss = webview.asWebviewUri(vscode.Uri.file(path.join(DIST_DIR, 'milkdown-theme.css')));
|
||||||
const csp = webview.cspSource;
|
const csp = webview.cspSource;
|
||||||
@@ -339,6 +779,7 @@ function buildHtml(webview) {
|
|||||||
.replace(/\{\{NONCE\}\}/g, nonce)
|
.replace(/\{\{NONCE\}\}/g, nonce)
|
||||||
.replace(/\{\{CSP_SOURCE\}\}/g, csp)
|
.replace(/\{\{CSP_SOURCE\}\}/g, csp)
|
||||||
.replace('{{MILKDOWN_JS}}', milkdownJs.toString())
|
.replace('{{MILKDOWN_JS}}', milkdownJs.toString())
|
||||||
|
.replace('{{SPELLCHECK_JS}}', spellcheckJs.toString())
|
||||||
.replace('{{COMMON_CSS}}', commonCss.toString())
|
.replace('{{COMMON_CSS}}', commonCss.toString())
|
||||||
.replace('{{THEME_CSS}}', themeCss.toString());
|
.replace('{{THEME_CSS}}', themeCss.toString());
|
||||||
}
|
}
|
||||||
@@ -391,7 +832,7 @@ function revertWebviewImageUris(content, websiteBaseUri) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles messages from the webview.
|
* 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} blogDir
|
||||||
* @param {string} websiteDir
|
* @param {string} websiteDir
|
||||||
*/
|
*/
|
||||||
@@ -486,6 +927,28 @@ function handleMessage(msg, blogDir, websiteDir) {
|
|||||||
sidebarProvider?.refresh();
|
sidebarProvider?.refresh();
|
||||||
break;
|
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': {
|
case 'ready': {
|
||||||
if (pendingOpenSlug) {
|
if (pendingOpenSlug) {
|
||||||
webview.postMessage({ type: 'openSlug', slug: pendingOpenSlug });
|
webview.postMessage({ type: 'openSlug', slug: pendingOpenSlug });
|
||||||
|
|||||||
@@ -2,11 +2,18 @@
|
|||||||
"name": "blog-editor",
|
"name": "blog-editor",
|
||||||
"displayName": "Blog Editor",
|
"displayName": "Blog Editor",
|
||||||
"description": "WYSIWYG Milkdown blog post editor for this workspace",
|
"description": "WYSIWYG Milkdown blog post editor for this workspace",
|
||||||
"version": "0.1.12",
|
"version": "0.1.32",
|
||||||
"publisher": "local",
|
"publisher": "local",
|
||||||
"engines": { "vscode": "^1.80.0" },
|
"engines": {
|
||||||
"categories": ["Other"],
|
"vscode": "^1.80.0"
|
||||||
"repository": { "type": "git", "url": "https://github.com/local/blog-editor" },
|
},
|
||||||
|
"categories": [
|
||||||
|
"Other"
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/local/blog-editor"
|
||||||
|
},
|
||||||
"activationEvents": [],
|
"activationEvents": [],
|
||||||
"main": "./extension.js",
|
"main": "./extension.js",
|
||||||
"contributes": {
|
"contributes": {
|
||||||
@@ -79,6 +86,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"package": "node ../build-dist.js && npx @vscode/vsce package --out blog-editor.vsix --no-dependencies"
|
"prepackage": "node ./bump-version.js",
|
||||||
|
"package": "node ./build-dist.js",
|
||||||
|
"postpackage": "npx @vscode/vsce package --out blog-editor.vsix --no-dependencies --allow-missing-repository"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
273
tools/blogeditor/spellcheck.js
Normal file
273
tools/blogeditor/spellcheck.js
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
import nspell from 'nspell';
|
||||||
|
import affData from 'dictionary-en-us/index.aff';
|
||||||
|
import dicData from 'dictionary-en-us/index.dic';
|
||||||
|
|
||||||
|
const spell = nspell(affData, dicData);
|
||||||
|
const SPELLCHECK_HIGHLIGHT = 'blog-editor-spellcheck';
|
||||||
|
const WORD_PATTERN = /[A-Za-z][A-Za-z'’-]{1,}/g;
|
||||||
|
const SKIP_TAGS = new Set(['CODE', 'PRE', 'A', 'KBD', 'SAMP']);
|
||||||
|
const PERSONAL_DICTIONARY_KEY = 'blog-editor-personal-dictionary-v1';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} value
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isWordChar(value) {
|
||||||
|
return /[A-Za-z'’-]/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight editor spellcheck controller using CSS Highlights.
|
||||||
|
*/
|
||||||
|
export class BlogEditorSpellcheck {
|
||||||
|
/**
|
||||||
|
* @param {HTMLElement} root
|
||||||
|
* @param {(message: string, isError?: boolean) => void} updateStatus
|
||||||
|
*/
|
||||||
|
constructor(root, updateStatus) {
|
||||||
|
this.root = root;
|
||||||
|
this.updateStatus = updateStatus;
|
||||||
|
this.enabled = true;
|
||||||
|
this.pendingTimer = 0;
|
||||||
|
this.highlightName = SPELLCHECK_HIGHLIGHT;
|
||||||
|
this.personalWords = new Set();
|
||||||
|
this.supported = typeof CSS !== 'undefined' && typeof CSS.highlights !== 'undefined' && typeof Highlight !== 'undefined';
|
||||||
|
this.loadPersonalDictionary();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {boolean} enabled
|
||||||
|
*/
|
||||||
|
setEnabled(enabled) {
|
||||||
|
this.enabled = enabled;
|
||||||
|
if (!this.supported) {
|
||||||
|
this.updateStatus(enabled ? 'spellcheck unavailable in this renderer' : 'spellcheck off', !enabled ? false : true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
this.clear();
|
||||||
|
this.updateStatus('spellcheck off');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
schedule() {
|
||||||
|
if (!this.supported || !this.enabled) return;
|
||||||
|
window.clearTimeout(this.pendingTimer);
|
||||||
|
this.pendingTimer = window.setTimeout(() => this.refresh(), 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh() {
|
||||||
|
if (!this.supported || !this.enabled) return;
|
||||||
|
const highlight = new Highlight();
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for (const node of this.iterTextNodes()) {
|
||||||
|
const text = node.textContent ?? '';
|
||||||
|
WORD_PATTERN.lastIndex = 0;
|
||||||
|
let match;
|
||||||
|
while ((match = WORD_PATTERN.exec(text)) !== null) {
|
||||||
|
const word = match[0];
|
||||||
|
if (!this.isMisspelledWord(word)) continue;
|
||||||
|
const range = new Range();
|
||||||
|
range.setStart(node, match.index);
|
||||||
|
range.setEnd(node, match.index + word.length);
|
||||||
|
highlight.add(range);
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CSS.highlights.set(this.highlightName, highlight);
|
||||||
|
this.updateStatus(count > 0 ? `spellcheck on · ${count} issue${count === 1 ? '' : 's'}` : 'spellcheck on · clear');
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
if (!this.supported) return;
|
||||||
|
window.clearTimeout(this.pendingTimer);
|
||||||
|
CSS.highlights.delete(this.highlightName);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the personal dictionary from browser storage.
|
||||||
|
*/
|
||||||
|
loadPersonalDictionary() {
|
||||||
|
if (typeof localStorage === 'undefined') return;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(PERSONAL_DICTIONARY_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
const words = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(words)) return;
|
||||||
|
for (const entry of words) {
|
||||||
|
if (typeof entry !== 'string' || !entry.trim()) continue;
|
||||||
|
const word = entry.trim();
|
||||||
|
this.personalWords.add(word);
|
||||||
|
spell.add(word);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.personalWords.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists the personal dictionary to browser storage.
|
||||||
|
*/
|
||||||
|
savePersonalDictionary() {
|
||||||
|
if (typeof localStorage === 'undefined') return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PERSONAL_DICTIONARY_KEY, JSON.stringify([...this.personalWords].sort((a, b) => a.localeCompare(b))));
|
||||||
|
} catch {
|
||||||
|
// Ignore storage quota / privacy errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a word to the personal dictionary and refreshes highlights.
|
||||||
|
* @param {string} word
|
||||||
|
*/
|
||||||
|
addToDictionary(word) {
|
||||||
|
const normalized = typeof word === 'string' ? word.trim() : '';
|
||||||
|
if (!normalized) return;
|
||||||
|
if (this.personalWords.has(normalized)) return;
|
||||||
|
this.personalWords.add(normalized);
|
||||||
|
spell.add(normalized);
|
||||||
|
this.savePersonalDictionary();
|
||||||
|
this.refresh();
|
||||||
|
this.updateStatus(`added "${normalized}" to dictionary`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} word
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
isMisspelledWord(word) {
|
||||||
|
if (!this.shouldCheckWord(word)) return false;
|
||||||
|
return !spell.correct(word);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} word
|
||||||
|
* @param {number} [limit]
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
getSuggestions(word, limit = 6) {
|
||||||
|
if (!word || !this.isMisspelledWord(word)) return [];
|
||||||
|
return spell.suggest(word).slice(0, Math.max(0, limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
*iterTextNodes() {
|
||||||
|
const walker = document.createTreeWalker(this.root, NodeFilter.SHOW_TEXT, {
|
||||||
|
acceptNode: node => {
|
||||||
|
if (!(node.parentElement instanceof HTMLElement)) return NodeFilter.FILTER_REJECT;
|
||||||
|
if (!node.textContent || !WORD_PATTERN.test(node.textContent)) return NodeFilter.FILTER_REJECT;
|
||||||
|
if (node.parentElement.closest('[data-type="image-block"]')) return NodeFilter.FILTER_REJECT;
|
||||||
|
if (this.shouldSkip(node.parentElement)) return NodeFilter.FILTER_REJECT;
|
||||||
|
return NodeFilter.FILTER_ACCEPT;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let current;
|
||||||
|
while ((current = walker.nextNode())) {
|
||||||
|
yield current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {HTMLElement} element
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
shouldSkip(element) {
|
||||||
|
let current = element;
|
||||||
|
while (current && current !== this.root) {
|
||||||
|
if (SKIP_TAGS.has(current.tagName)) return true;
|
||||||
|
if (current.getAttribute('contenteditable') === 'false') return true;
|
||||||
|
current = current.parentElement;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the misspelled word under a viewport point, if any.
|
||||||
|
* @param {number} clientX
|
||||||
|
* @param {number} clientY
|
||||||
|
* @returns {string | null}
|
||||||
|
*/
|
||||||
|
getMisspelledWordAtPoint(clientX, clientY) {
|
||||||
|
const hit = this.getMisspelledAtPoint(clientX, clientY);
|
||||||
|
return hit ? hit.word : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns misspelled word details under a viewport point, if any.
|
||||||
|
* @param {number} clientX
|
||||||
|
* @param {number} clientY
|
||||||
|
* @returns {{ word: string, range: Range } | null}
|
||||||
|
*/
|
||||||
|
getMisspelledAtPoint(clientX, clientY) {
|
||||||
|
if (!this.supported) return null;
|
||||||
|
|
||||||
|
let node = null;
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
if (typeof document.caretPositionFromPoint === 'function') {
|
||||||
|
const pos = document.caretPositionFromPoint(clientX, clientY);
|
||||||
|
if (pos) {
|
||||||
|
node = pos.offsetNode;
|
||||||
|
offset = pos.offset;
|
||||||
|
}
|
||||||
|
} else if (typeof document.caretRangeFromPoint === 'function') {
|
||||||
|
const range = document.caretRangeFromPoint(clientX, clientY);
|
||||||
|
if (range) {
|
||||||
|
node = range.startContainer;
|
||||||
|
offset = range.startOffset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(node instanceof Text)) return null;
|
||||||
|
if (!(node.parentElement instanceof HTMLElement)) return null;
|
||||||
|
if (this.shouldSkip(node.parentElement)) return null;
|
||||||
|
|
||||||
|
const text = node.textContent ?? '';
|
||||||
|
if (!text) return null;
|
||||||
|
|
||||||
|
let start = Math.min(Math.max(offset, 0), text.length);
|
||||||
|
let end = start;
|
||||||
|
|
||||||
|
if (start > 0 && !isWordChar(text[start]) && isWordChar(text[start - 1])) {
|
||||||
|
start -= 1;
|
||||||
|
end = start;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (start > 0 && isWordChar(text[start - 1])) start -= 1;
|
||||||
|
while (end < text.length && isWordChar(text[end])) end += 1;
|
||||||
|
|
||||||
|
if (end <= start) return null;
|
||||||
|
|
||||||
|
const word = text.slice(start, end);
|
||||||
|
if (!this.isMisspelledWord(word)) return null;
|
||||||
|
|
||||||
|
const range = new Range();
|
||||||
|
range.setStart(node, start);
|
||||||
|
range.setEnd(node, end);
|
||||||
|
return { word, range };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} word
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
shouldCheckWord(word) {
|
||||||
|
if (word.length < 3) return false;
|
||||||
|
if (/^[A-Z0-9_-]+$/.test(word)) return false;
|
||||||
|
if (/\d/.test(word)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
1411
website/scripts/BlogComments.js
Normal file
1411
website/scripts/BlogComments.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
|||||||
import { MarkdownRenderer } from "./MarkdownRenderer.js";
|
import { MarkdownRenderer } from "./MarkdownRenderer.js";
|
||||||
|
import { BlogComments } from "./BlogComments.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decodes markdown payload from a JSON script element.
|
* Decodes markdown payload from a JSON script element.
|
||||||
@@ -65,6 +66,33 @@ function renderStaticBlogPost() {
|
|||||||
wireCopyButtons(target);
|
wireCopyButtons(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mounts Matrix-backed comments/reactions for the current blog post.
|
||||||
|
*
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function setupBlogComments() {
|
||||||
|
/** @type {HTMLElement | null} */
|
||||||
|
const mount = document.querySelector("[data-blog-comments]");
|
||||||
|
if (!mount) return;
|
||||||
|
|
||||||
|
const slug = window.location.pathname
|
||||||
|
.split("/")
|
||||||
|
.filter(Boolean)
|
||||||
|
.pop() || "unknown";
|
||||||
|
|
||||||
|
const homeserver = mount.dataset.matrixHomeserver || "https://chat.ruv.wtf";
|
||||||
|
const roomAlias = mount.dataset.matrixRoom || "#general:chat.ruv.wtf";
|
||||||
|
|
||||||
|
const comments = new BlogComments({
|
||||||
|
homeserver,
|
||||||
|
roomAlias,
|
||||||
|
postSlug: slug
|
||||||
|
});
|
||||||
|
|
||||||
|
await comments.initialize(mount);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggles the compact top bar when the hero logo scrolls out of view.
|
* Toggles the compact top bar when the hero logo scrolls out of view.
|
||||||
*
|
*
|
||||||
@@ -200,10 +228,11 @@ function setupPeekSplines() {
|
|||||||
*
|
*
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
function initializeBlogPage() {
|
async function initializeBlogPage() {
|
||||||
renderStaticBlogPost();
|
renderStaticBlogPost();
|
||||||
setupScrollTopBar();
|
setupScrollTopBar();
|
||||||
setupPeekSplines();
|
setupPeekSplines();
|
||||||
|
await setupBlogComments();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
if (document.readyState === "loading") {
|
||||||
|
|||||||
@@ -940,13 +940,13 @@ body.blog-page {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.blog-post-peek {
|
.blog-post-peek {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
|
||||||
.blog-post-splines {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
.blog-post-splines {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.blog-post-title {
|
.blog-post-title {
|
||||||
@@ -1245,6 +1245,366 @@ body.blog-page {
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.blog-comments {
|
||||||
|
margin-top: 28px;
|
||||||
|
border-top: 1px solid rgba(205, 214, 244, 0.12);
|
||||||
|
padding-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-title {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-status {
|
||||||
|
margin-top: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-status.is-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments.is-loading .blog-comments-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments.is-loading .blog-comments-status::before {
|
||||||
|
content: "";
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(205, 214, 244, 0.28);
|
||||||
|
border-top-color: var(--ctp-blue);
|
||||||
|
animation: blog-comments-spin 0.65s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-status.is-ok {
|
||||||
|
color: var(--ctp-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-status.is-error,
|
||||||
|
.blog-comments-system.is-error {
|
||||||
|
color: var(--ctp-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-post-reactions-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-item {
|
||||||
|
background: rgba(24, 24, 37, 0.58);
|
||||||
|
border: 1px solid rgba(205, 214, 244, 0.12);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-author {
|
||||||
|
color: var(--ctp-text);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-author-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-handle {
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 400;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-owner-badge {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-time {
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-body {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ctp-subtext1);
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-reactions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-reaction-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(205, 214, 244, 0.2);
|
||||||
|
background: rgba(49, 50, 68, 0.8);
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-reaction-emoji,
|
||||||
|
.blog-reaction-count {
|
||||||
|
display: inline;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-reaction-chip.is-own {
|
||||||
|
border-color: rgba(166, 227, 161, 0.7);
|
||||||
|
background: rgba(166, 227, 161, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-action-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 2px 7px;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-action-btn:hover {
|
||||||
|
background: rgba(205, 214, 244, 0.08);
|
||||||
|
color: var(--ctp-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-action-btn.is-danger:hover {
|
||||||
|
color: var(--ctp-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-action-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-edit-area {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-edit-input {
|
||||||
|
flex: 1;
|
||||||
|
border: 1px solid rgba(205, 214, 244, 0.2);
|
||||||
|
background: rgba(24, 24, 37, 0.85);
|
||||||
|
border-radius: 7px;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
padding: 5px 9px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-edit-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--ctp-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-reaction-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
border: 1px solid rgba(205, 214, 244, 0.18);
|
||||||
|
background: rgba(49, 50, 68, 0.7);
|
||||||
|
color: var(--ctp-text);
|
||||||
|
border-radius: 8px;
|
||||||
|
min-width: 34px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-reaction-btn.is-own {
|
||||||
|
border-color: rgba(166, 227, 161, 0.7);
|
||||||
|
background: rgba(166, 227, 161, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-reaction-btn-emoji {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-reaction-btn-count {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--ctp-subtext1);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-reaction-btn-count.is-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-composer {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid rgba(205, 214, 244, 0.2);
|
||||||
|
background: rgba(24, 24, 37, 0.85);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--ctp-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-send {
|
||||||
|
border: 1px solid rgba(205, 214, 244, 0.22);
|
||||||
|
background: rgba(137, 180, 250, 0.2);
|
||||||
|
color: var(--ctp-text);
|
||||||
|
border-radius: 10px;
|
||||||
|
min-width: 78px;
|
||||||
|
padding: 0 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-send:hover {
|
||||||
|
background: rgba(137, 180, 250, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-send:disabled,
|
||||||
|
.blog-comments-input:disabled {
|
||||||
|
opacity: 0.65;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-system {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-identity {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--ctp-subtext1);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-identity.is-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-identity-label {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-identity-name {
|
||||||
|
color: var(--ctp-text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-identity-edit-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-identity-edit-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-identity-input {
|
||||||
|
border: 1px solid rgba(205, 214, 244, 0.25);
|
||||||
|
background: rgba(24, 24, 37, 0.85);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-identity-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--ctp-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-identity-save-btn {
|
||||||
|
background: rgba(137, 180, 250, 0.2);
|
||||||
|
border: 1px solid rgba(137, 180, 250, 0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-identity-save-btn:hover {
|
||||||
|
background: rgba(137, 180, 250, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blog-comments-spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
.blog-index-list {
|
.blog-index-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -1281,6 +1641,14 @@ body.blog-page {
|
|||||||
padding: 18px 16px 22px;
|
padding: 18px 16px 22px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.blog-comments-composer {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-send {
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── HUD ─────────────────────────────────────────────────────────────────────── */
|
/* ─── HUD ─────────────────────────────────────────────────────────────────────── */
|
||||||
|
|||||||
Reference in New Issue
Block a user