mirror of
https://github.com/litruv/lit.ruv.wtf.git
synced 2026-07-26 11:46:04 +10:00
Compare commits
5 Commits
13fa54401f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a4926c1368 | |||
| b09a329d62 | |||
| 655d16ea6b | |||
| cf08709151 | |||
| 37d6388244 |
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
website/data/blog/media/paste-1778545573604-pe0k3.png
Normal file
BIN
website/data/blog/media/paste-1778545573604-pe0k3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.1 MiB |
@@ -6,7 +6,7 @@ tags: announcement
|
|||||||
---
|
---
|
||||||
Congratulations on making it here, you've stumbled onto a game dev's blog, who's spent the past 12 years buried in Unreal Engine.
|
Congratulations on making it here, you've stumbled onto a game dev's blog, who's spent the past 12 years buried in Unreal Engine.
|
||||||
|
|
||||||
From studying Programming and 3D Art & Animation at the Academy of Interactive Entertainment, to developing architectural experiences at [Orbit Solutions](https://www.orbitsolutions.com.au/), running 1-on-1 mentoring sessions on [Fiverr](https://www.fiverr.com/litruv), working with [Superfarm](https://superverse.co/), and now building my own games and tools with [Mates.dev](https://mates.dev) - this blog is where I will be dumping my processes.
|
From studying Programming and 3D Art & Animation at the Academy of Interactive Entertainment, to developing architectural experiences at [Orbit Solutions](https://www.orbitsolutions.com.au/), running 1-on-1 mentoring sessions on [Fiverr](https://www.fiverr.com/litruv), working with [Superfarm](https://superverse.co/) and now building my own games and tools with [Mates.dev](https://mates.dev) - this blog is where I will be dumping my processes.
|
||||||
|
|
||||||
Expect game dev, systems design, experiments, tools, prototypes, and the occasional questionable decision that somehow worked out in the end.
|
Expect game dev, systems design, experiments, tools, prototypes, and the occasional questionable decision that somehow worked out in the end.
|
||||||
|
|
||||||
@@ -22,6 +22,8 @@ A lot of my work seems to revolve around systems-heavy gameplay, tools, modding
|
|||||||
|
|
||||||
These days I'm focused on building my own games and tech, while trying to document more of the process instead of letting years of experiments vanish into forgotten folders and messages.
|
These days I'm focused on building my own games and tech, while trying to document more of the process instead of letting years of experiments vanish into forgotten folders and messages.
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
This blog is mostly going to be a mix of development logs, technical breakdowns, experiments, ideas, failures, discoveries and whatever else crawls out of the workshop. Some posts will just document strange decisions made at unreasonable hours, the others might even be useful. Both are worth keeping around.
|
This blog is mostly going to be a mix of development logs, technical breakdowns, experiments, ideas, failures, discoveries and whatever else crawls out of the workshop. Some posts will just document strange decisions made at unreasonable hours, the others might even be useful. Both are worth keeping around.
|
||||||
|
|||||||
1428
website/scripts/BlogComments.js
Normal file
1428
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.
|
||||||
*
|
*
|
||||||
@@ -86,9 +114,9 @@ function setupScrollTopBar() {
|
|||||||
if (scrollTop < 80) {
|
if (scrollTop < 80) {
|
||||||
body.classList.remove("blog-scrolled");
|
body.classList.remove("blog-scrolled");
|
||||||
} else if (scrollingDown) {
|
} else if (scrollingDown) {
|
||||||
body.classList.add("blog-scrolled");
|
|
||||||
} else {
|
|
||||||
body.classList.remove("blog-scrolled");
|
body.classList.remove("blog-scrolled");
|
||||||
|
} else {
|
||||||
|
body.classList.add("blog-scrolled");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
body.addEventListener("scroll", applyState, { passive: true });
|
body.addEventListener("scroll", applyState, { passive: true });
|
||||||
@@ -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") {
|
||||||
|
|||||||
@@ -477,7 +477,7 @@ svg.world-image {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 3px 8px;
|
padding: 0 4px;
|
||||||
background: rgba(137, 180, 250, 0.15);
|
background: rgba(137, 180, 250, 0.15);
|
||||||
color: rgba(137, 180, 250, 0.9);
|
color: rgba(137, 180, 250, 0.9);
|
||||||
border: 1px solid rgba(137, 180, 250, 0.3);
|
border: 1px solid rgba(137, 180, 250, 0.3);
|
||||||
@@ -778,7 +778,7 @@ body.blog-page {
|
|||||||
.blog-post-peek-wrapper {
|
.blog-post-peek-wrapper {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
width: 160px;
|
min-width: 60px;`n max-width: 250px;
|
||||||
height: 0;
|
height: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
@@ -793,7 +793,7 @@ body.blog-page {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.blog-post-peek {
|
.blog-post-peek {
|
||||||
width: 160px;
|
min-width: 60px;`n max-width: 250px;
|
||||||
background: var(--node-bg);
|
background: var(--node-bg);
|
||||||
border: 1px solid var(--node-border);
|
border: 1px solid var(--node-border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -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 {
|
||||||
@@ -981,7 +981,7 @@ body.blog-page {
|
|||||||
border: 1px solid rgba(137, 180, 250, 0.35);
|
border: 1px solid rgba(137, 180, 250, 0.35);
|
||||||
background: rgba(137, 180, 250, 0.12);
|
background: rgba(137, 180, 250, 0.12);
|
||||||
color: var(--ctp-sky);
|
color: var(--ctp-sky);
|
||||||
padding: 3px 8px;
|
padding: 0 4px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
@@ -1245,6 +1245,390 @@ 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: 0 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
min-width: 60px;`n max-width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-identity-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--ctp-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-identity-name-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;
|
||||||
|
min-width: 60px;
|
||||||
|
max-width: 250px;
|
||||||
|
vertical-align: baseline;
|
||||||
|
transition: border-color 0.15s, background 0.15s, width 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-identity-name-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--ctp-blue);
|
||||||
|
background: rgba(69, 71, 90, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-identity-name-input:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 +1665,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 ─────────────────────────────────────────────────────────────────────── */
|
||||||
@@ -2699,7 +3091,7 @@ body.blog-page {
|
|||||||
color: rgba(243, 139, 168, 0.9);
|
color: rgba(243, 139, 168, 0.9);
|
||||||
border: 1px solid rgba(243, 139, 168, 0.3);
|
border: 1px solid rgba(243, 139, 168, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 3px 8px;
|
padding: 0 4px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
@@ -2756,7 +3148,7 @@ body.blog-page {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
padding: 3px 8px;
|
padding: 0 4px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user