diff --git a/package-lock.json b/package-lock.json
index d9b2f27..59d10f7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,7 +15,9 @@
"devDependencies": {
"@milkdown/crepe": "^7.20.0",
"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": {
@@ -2493,6 +2495,17 @@
"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": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz",
@@ -2766,6 +2779,30 @@
"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": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -3830,6 +3867,16 @@
"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": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz",
diff --git a/package.json b/package.json
index 32c36a3..271ad16 100644
--- a/package.json
+++ b/package.json
@@ -32,6 +32,8 @@
"devDependencies": {
"@milkdown/crepe": "^7.20.0",
"clean-css-cli": "^5.6.3",
- "esbuild": "^0.28.0"
+ "dictionary-en-us": "^2.2.1",
+ "esbuild": "^0.28.0",
+ "nspell": "^2.1.5"
}
}
diff --git a/tools/StaticBlogGenerator.js b/tools/StaticBlogGenerator.js
index ce7094d..4cd3164 100644
--- a/tools/StaticBlogGenerator.js
+++ b/tools/StaticBlogGenerator.js
@@ -170,6 +170,7 @@ class StaticBlogGenerator {
' ',
'
',
' ',
+ ' ',
` `,
'',
nextPeek,
diff --git a/tools/blogeditor/blog-editor.vsix b/tools/blogeditor/blog-editor.vsix
new file mode 100644
index 0000000..b48dee7
Binary files /dev/null and b/tools/blogeditor/blog-editor.vsix differ
diff --git a/tools/blogeditor/build-dist.js b/tools/blogeditor/build-dist.js
index 8201cf5..d2732c8 100644
--- a/tools/blogeditor/build-dist.js
+++ b/tools/blogeditor/build-dist.js
@@ -50,6 +50,11 @@ const FONT_LOADERS = {
'.svg': 'dataurl',
};
+const TEXT_LOADERS = {
+ '.aff': 'text',
+ '.dic': 'text',
+};
+
async function main() {
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);
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');
}
diff --git a/tools/blogeditor/bump-version.js b/tools/blogeditor/bump-version.js
new file mode 100644
index 0000000..8a19992
--- /dev/null
+++ b/tools/blogeditor/bump-version.js
@@ -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();
diff --git a/tools/blogeditor/editor.html b/tools/blogeditor/editor.html
index 4bdb95e..0b2c881 100644
--- a/tools/blogeditor/editor.html
+++ b/tools/blogeditor/editor.html
@@ -163,6 +163,86 @@
.fm-field.date-field { flex: 0.8 1 150px; min-width: 150px; }
.fm-field.author-field { flex: 1.1 1 180px; min-width: 160px; }
.fm-field.tags-field { flex: 1.8 1 240px; min-width: 200px; }
+ .fm-field-group {
+ display: block;
+ min-width: 0;
+ }
+ .tags-field-group {
+ align-self: flex-start;
+ display: block;
+ position: relative;
+ flex: 1.8 1 240px;
+ min-width: 200px;
+ height: 34px;
+ }
+ .tags-field-row {
+ display: flex;
+ align-items: stretch;
+ gap: 8px;
+ height: 34px;
+ }
+ .fm-field.tags-field {
+ flex: 1 1 auto;
+ height: 34px;
+ }
+ .ai-tag-suggestions {
+ position: absolute;
+ top: calc(100% + 6px);
+ left: 0;
+ right: 0;
+ z-index: 40;
+ display: none;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: center;
+ padding: 6px;
+ border-radius: 8px;
+ border: 1px solid rgba(137, 180, 250, 0.22);
+ background: rgba(24, 24, 37, 0.95);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
+ backdrop-filter: blur(10px);
+ }
+ .ai-tag-suggestions.is-visible {
+ display: flex;
+ }
+ .ai-tag-pill {
+ appearance: none;
+ border: 1px solid rgba(137, 180, 250, 0.35);
+ background: rgba(137, 180, 250, 0.12);
+ color: var(--sky);
+ border-radius: 999px;
+ padding: 2px 10px;
+ font-size: 0.72rem;
+ line-height: 1.4;
+ cursor: pointer;
+ transition: background 120ms ease, border-color 120ms ease, color 120ms ease, opacity 120ms ease;
+ }
+ .ai-tag-pill:hover {
+ background: rgba(137, 180, 250, 0.2);
+ border-color: rgba(137, 180, 250, 0.52);
+ color: var(--text);
+ }
+ .ai-tag-pill:disabled {
+ cursor: default;
+ opacity: 0.55;
+ color: var(--sub0);
+ border-color: rgba(166, 173, 200, 0.22);
+ background: rgba(166, 173, 200, 0.12);
+ }
+ .ai-tag-pill-clear {
+ margin-left: auto;
+ min-width: 24px;
+ padding: 2px 8px;
+ border-color: rgba(243, 139, 168, 0.4);
+ background: rgba(243, 139, 168, 0.14);
+ color: var(--red);
+ font-weight: 700;
+ }
+ .ai-tag-pill-clear:hover {
+ border-color: rgba(243, 139, 168, 0.7);
+ background: rgba(243, 139, 168, 0.24);
+ color: var(--text);
+ }
#fm-slug { font-family: "Cascadia Code", "Fira Code", ui-monospace, monospace; font-size: 0.75rem; color: var(--sub0); }
#fm-date { color-scheme: dark; }
@@ -326,6 +406,8 @@
}
#status.dirty { color: var(--peach); }
#status.saved { color: var(--green); }
+ #status.finding { color: var(--blue); }
+ #status.not-found { color: var(--red); }
#btn-save {
background: transparent;
color: var(--blue);
@@ -344,9 +426,163 @@
}
#btn-save:hover { background: rgba(137, 180, 250, 0.12); opacity: 1; }
#btn-save:disabled { background: transparent; color: var(--sub0); cursor: default; opacity: 1; }
+ #btn-spell {
+ background: transparent;
+ color: var(--sub0);
+ border: none;
+ border-radius: 6px;
+ width: 36px;
+ height: 28px;
+ padding: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.8rem;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ cursor: pointer;
+ line-height: 1;
+ text-transform: uppercase;
+ }
+ #btn-spell:hover { background: rgba(137, 180, 250, 0.12); }
+ #btn-spell.on { color: var(--green); }
+ #btn-ai-tags {
+ background: var(--surface0);
+ color: var(--sub0);
+ border: 1px solid var(--surface1);
+ border-radius: 6px;
+ width: 34px;
+ height: 34px;
+ padding: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 1rem;
+ cursor: pointer;
+ line-height: 1;
+ flex: 0 0 auto;
+ }
+ #btn-ai-tags:hover { background: rgba(137, 180, 250, 0.12); border-color: rgba(137, 180, 250, 0.4); color: var(--text); }
+ #btn-ai-tags.busy { color: var(--yellow); }
+ #btn-ai-tags:disabled { opacity: 0.65; cursor: default; }
+
+ #findbar {
+ position: fixed;
+ top: 0;
+ right: 10px;
+ z-index: 50;
+ display: none;
+ align-items: center;
+ gap: 6px;
+ padding: 6px;
+ background: var(--mantle);
+ border: 1px solid var(--surface1);
+ border-radius: 8px;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
+ }
+ #findbar.visible { display: inline-flex; }
+ #find-input {
+ width: 220px;
+ height: 28px;
+ background: var(--surface0);
+ color: var(--text);
+ border: 1px solid var(--surface1);
+ border-radius: 6px;
+ padding: 0 8px;
+ outline: none;
+ font-size: 0.8rem;
+ }
+ #find-input:focus { border-color: var(--blue); }
+ #find-count {
+ min-width: 58px;
+ text-align: right;
+ color: var(--sub0);
+ font-size: 0.75rem;
+ font-family: "Cascadia Code", "Fira Code", ui-monospace, monospace;
+ user-select: none;
+ }
+ .find-btn {
+ width: 30px;
+ height: 28px;
+ border: none;
+ border-radius: 6px;
+ background: var(--surface0);
+ color: var(--sub1);
+ font-size: 0.86rem;
+ cursor: pointer;
+ }
+ .find-btn:hover { background: var(--surface1); color: var(--text); }
+ #find-close { font-size: 1.05rem; line-height: 1; }
+
+ #spell-menu {
+ position: fixed;
+ z-index: 80;
+ display: none;
+ min-width: 180px;
+ background: var(--mantle);
+ border: 1px solid var(--surface1);
+ border-radius: 8px;
+ padding: 6px;
+ box-shadow: 0 18px 38px rgba(0, 0, 0, 0.45);
+ }
+ #spell-menu.visible { display: block; }
+ .spell-menu-group {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ margin-bottom: 6px;
+ padding-bottom: 6px;
+ border-bottom: 1px solid var(--surface0);
+ }
+ .spell-menu-item {
+ width: 100%;
+ border: none;
+ background: transparent;
+ color: var(--text);
+ text-align: left;
+ padding: 8px 10px;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 0.82rem;
+ }
+ .spell-menu-item:hover { background: var(--surface0); }
+ .spell-menu-item:disabled {
+ color: var(--sub0);
+ cursor: default;
+ }
+
+ ::highlight(blog-editor-spellcheck) {
+ background: transparent;
+ text-decoration-line: underline;
+ text-decoration-style: wavy;
+ text-decoration-color: var(--red);
+ text-decoration-thickness: 1px;
+ text-underline-offset: 0.12em;
+ text-decoration-skip-ink: auto;
+ }
+ ::highlight(blog-editor-find-match) {
+ background: rgba(249, 226, 175, 0.25);
+ border-radius: 2px;
+ }
+ ::highlight(blog-editor-find-active) {
+ background: rgba(249, 226, 175, 0.65);
+ color: var(--crust);
+ border-radius: 2px;
+ }
+
+
+
+ 0 / 0
+
+
+
+
@@ -383,6 +626,7 @@
diff --git a/tools/blogeditor/extension.js b/tools/blogeditor/extension.js
index c6b7677..a4e0a9b 100644
--- a/tools/blogeditor/extension.js
+++ b/tools/blogeditor/extension.js
@@ -4,10 +4,12 @@ const vscode = require('vscode');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
+const extensionManifest = require('./package.json');
const EXT_DIR = __dirname;
const DIST_DIR = path.join(EXT_DIR, 'dist');
const ROOT_DIR = path.resolve(EXT_DIR, '..');
+const EXTENSION_VERSION = extensionManifest.version;
/** @type {vscode.WebviewPanel | undefined} */
let panel;
@@ -21,6 +23,26 @@ let pendingOpenSlug;
/** @type {BlogSidebarProvider | undefined} */
let sidebarProvider;
+/** @type {vscode.OutputChannel | undefined} */
+let output;
+
+const DEFAULT_LM_STUDIO_BASE_URL = 'http://127.0.0.1:1234';
+const LM_STUDIO_CHAT_ENDPOINTS = ['/v1/chat/completions'];
+const LM_STUDIO_MODELS_ENDPOINTS = ['/api/v1/models', '/v1/models'];
+const BLOG_TAGS_JSON_SCHEMA = {
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
+ title: 'Blog Tags',
+ type: 'array',
+ maxItems: 20,
+ uniqueItems: true,
+ items: {
+ type: 'string',
+ minLength: 1,
+ maxLength: 32,
+ pattern: '^[a-z0-9]+(-[a-z0-9]+)*$',
+ },
+};
+
/**
* @implements {vscode.TreeDataProvider
}
*/
@@ -84,7 +106,7 @@ class BlogSidebarProvider {
/**
* Parses YAML-ish frontmatter from a markdown file.
* @param {string} filePath
- * @returns {{ title?: string, date?: string }}
+ * @returns {{ title?: string, date?: string, author?: string, tags?: string }}
*/
function parseFrontmatterFromFile(filePath) {
try {
@@ -101,6 +123,405 @@ function parseFrontmatterFromFile(filePath) {
} catch { return {}; }
}
+/**
+ * @param {string} value
+ * @returns {number}
+ */
+function parseDateToMs(value) {
+ if (!value) return 0;
+ const ts = Date.parse(value);
+ return Number.isFinite(ts) ? ts : 0;
+}
+
+/**
+ * Gets best previous author by most recent post date (fallback: file mtime).
+ * @param {string} blogDir
+ * @returns {string}
+ */
+function getPreviousAuthor(blogDir) {
+ if (!fs.existsSync(blogDir)) return '';
+ const entries = fs.readdirSync(blogDir)
+ .filter(f => f.endsWith('.md') && !f.startsWith('.'))
+ .map(file => {
+ const filePath = path.join(blogDir, file);
+ const meta = parseFrontmatterFromFile(filePath);
+ const mtimeMs = fs.statSync(filePath).mtimeMs;
+ return { author: (meta.author || '').trim(), dateMs: parseDateToMs(meta.date || ''), mtimeMs };
+ })
+ .filter(item => item.author)
+ .sort((a, b) => {
+ if (a.dateMs !== b.dateMs) return b.dateMs - a.dateMs;
+ return b.mtimeMs - a.mtimeMs;
+ });
+ return entries[0]?.author || '';
+}
+
+/**
+ * @param {string} raw
+ * @returns {string[]}
+ */
+function parseTagArray(raw) {
+ if (typeof raw !== 'string') {
+ if (raw && typeof raw === 'object' && Array.isArray(raw.tags)) {
+ return raw.tags
+ .map(v => String(v).trim().replace(/^#+/, '').toLowerCase())
+ .filter(Boolean);
+ }
+ return [];
+ }
+
+ const cleaned = raw
+ .replace(/^```(?:json)?/i, '')
+ .replace(/```$/i, '')
+ .trim();
+
+ if (cleaned.startsWith('{') && cleaned.endsWith('}')) {
+ try {
+ const parsed = JSON.parse(cleaned);
+ if (parsed && typeof parsed === 'object' && Array.isArray(parsed.tags)) {
+ return parsed.tags
+ .map(v => String(v).trim().replace(/^#+/, '').toLowerCase())
+ .filter(Boolean);
+ }
+ } catch {
+ // Fall through to array and split parsing.
+ }
+ }
+
+ const start = cleaned.indexOf('[');
+ const end = cleaned.lastIndexOf(']');
+ if (start >= 0 && end > start) {
+ try {
+ const parsed = JSON.parse(cleaned.slice(start, end + 1));
+ if (Array.isArray(parsed)) {
+ return parsed
+ .map(v => String(v).trim().replace(/^#+/, '').toLowerCase())
+ .filter(Boolean);
+ }
+ } catch {
+ // Fall through to comma/newline split.
+ }
+ }
+
+ return cleaned
+ .split(/[\n,]/)
+ .map(v => v.replace(/^[\s\-•*#]+/, '').trim().toLowerCase())
+ .filter(Boolean);
+}
+
+/**
+ * @param {string[]} tags
+ * @returns {string[]}
+ */
+function normalizeGeneratedTags(tags) {
+ /** @type {string[]} */
+ const out = [];
+ const seen = new Set();
+ for (const tag of tags) {
+ const clean = tag
+ .replace(/^#+/, '')
+ .replace(/["'`]/g, '')
+ .trim()
+ .toLowerCase();
+ if (!clean || seen.has(clean)) continue;
+ seen.add(clean);
+ out.push(clean);
+ if (out.length >= 12) break;
+ }
+ return out;
+}
+
+const FALLBACK_TAG_RULES = [
+ { tag: 'unreal-engine', patterns: [/\bunreal engine\b/i, /\bue\b/i] },
+ { tag: 'game-development', patterns: [/\bgame dev\b/i, /\bgame development\b/i, /\bgamedev\b/i] },
+ { tag: 'programming', patterns: [/\bprogramming\b/i, /\bdeveloper\b/i, /\bdevelopment\b/i] },
+ { tag: '3d-art', patterns: [/\b3d art\b/i, /\b3d\b/i] },
+ { tag: 'animation', patterns: [/\banimation\b/i, /\banimations\b/i] },
+ { tag: 'systems-design', patterns: [/\bsystems design\b/i, /\binteraction systems\b/i, /\bsystems-heavy\b/i] },
+ { tag: 'tools', patterns: [/\btools\b/i, /\btooling\b/i] },
+ { tag: 'development-logs', patterns: [/\bdevelopment logs\b/i, /\bdev logs\b/i, /\blog\b/i] },
+ { tag: 'embedded-devices', patterns: [/\bembedded devices\b/i, /\bembedded\b/i] },
+ { tag: 'hardware', patterns: [/\bhardware\b/i, /\baudio gear\b/i] },
+ { tag: 'ui-frameworks', patterns: [/\bui frameworks\b/i, /\bui framework\b/i] },
+ { tag: 'modding', patterns: [/\bmodding\b/i, /\bmodding workflows\b/i] },
+ { tag: 'prototypes', patterns: [/\bprototypes\b/i, /\bprototype\b/i] },
+ { tag: 'experiments', patterns: [/\bexperiments\b/i, /\bexperiment\b/i] },
+];
+
+const FALLBACK_TAG_STOP_WORDS = new Set([
+ 'a', 'an', 'and', 'are', 'as', 'at', 'be', 'blog', 'both', 'but', 'by', 'for',
+ 'from', 'here', 'into', 'its', 'just', 'make', 'more', 'onto', 'post', 'that',
+ 'the', 'their', 'this', 'those', 'through', 'use', 'useful', 'using', 'welcome',
+ 'with', 'worth', 'you', 'your',
+]);
+
+/**
+ * @param {{ title: string, body: string, tags: string }} payload
+ * @returns {string[]}
+ */
+function buildFallbackTags(payload) {
+ const sourceText = `${payload.title || ''}\n${payload.body || ''}`;
+ const fallback = normalizeGeneratedTags(parseTagArray(payload.tags || ''));
+ const seen = new Set(fallback);
+
+ for (const rule of FALLBACK_TAG_RULES) {
+ if (seen.has(rule.tag)) continue;
+ if (rule.patterns.some(pattern => pattern.test(sourceText))) {
+ fallback.push(rule.tag);
+ seen.add(rule.tag);
+ }
+ if (fallback.length >= 8) return fallback;
+ }
+
+ const titleTokens = (payload.title || '')
+ .toLowerCase()
+ .match(/[a-z0-9]+/g) || [];
+ for (const token of titleTokens) {
+ if (token.length < 4 || FALLBACK_TAG_STOP_WORDS.has(token) || seen.has(token)) continue;
+ fallback.push(token);
+ seen.add(token);
+ if (fallback.length >= 8) break;
+ }
+
+ return normalizeGeneratedTags(fallback);
+}
+
+/**
+ * @param {string} message
+ */
+function log(message) {
+ const ts = new Date().toISOString();
+ output?.appendLine(`[${ts}] ${message}`);
+}
+
+/**
+ * @param {string | undefined} urlOrBase
+ * @returns {string}
+ */
+function resolveLmStudioBaseUrl(urlOrBase) {
+ const raw = (urlOrBase || '').trim();
+ if (!raw) return DEFAULT_LM_STUDIO_BASE_URL;
+ try {
+ const parsed = new URL(raw);
+ parsed.search = '';
+ parsed.hash = '';
+ const pathname = parsed.pathname || '/';
+ const cut = pathname.search(/\/(api\/v1|v1)\b/i);
+ parsed.pathname = cut >= 0 ? pathname.slice(0, cut) || '/' : pathname;
+ return parsed.toString().replace(/\/$/, '');
+ } catch {
+ return DEFAULT_LM_STUDIO_BASE_URL;
+ }
+}
+
+/**
+ * @param {string} baseUrl
+ * @returns {string[]}
+ */
+function buildChatEndpointCandidates(baseUrl) {
+ return LM_STUDIO_CHAT_ENDPOINTS.map(p => `${baseUrl}${p}`);
+}
+
+/**
+ * @param {unknown} data
+ * @returns {string}
+ */
+function extractLmStudioMessageContent(data) {
+ if (!data || typeof data !== 'object') return '';
+ const obj = /** @type {Record} */ (data);
+
+ const choices = Array.isArray(obj.choices) ? obj.choices : [];
+ for (const choice of choices) {
+ if (!choice || typeof choice !== 'object') continue;
+ const c = /** @type {Record} */ (choice);
+ const message = c.message;
+ if (message && typeof message === 'object') {
+ const messageObj = /** @type {Record} */ (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}
+ */
+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} body
+ * @param {AbortSignal} signal
+ * @returns {Promise}
+ */
+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}
+ */
+async function generateTagsWithLmStudio(payload) {
+ const configuredUrl = process.env.BLOG_EDITOR_LM_STUDIO_URL;
+ const baseUrl = resolveLmStudioBaseUrl(configuredUrl);
+ const endpoints = buildChatEndpointCandidates(baseUrl);
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 90000);
+
+ const prompt = [
+ 'Generate 3 to 8 relevant blog tags.',
+ 'Return at least 1 tag even if the post is sparse.',
+ 'Keep any existing tag if it is still relevant.',
+ 'Prefer concrete topical tags over generic filler.',
+ `Title: ${payload.title || '(untitled)'}`,
+ `Existing tags: ${payload.tags || '(none)'}`,
+ 'Body:',
+ (payload.body || '').slice(0, 6000),
+ ].join('\n\n');
+
+ try {
+ log(`AI tags: request started (version=${EXTENSION_VERSION}, title=${payload.title ? 'yes' : 'no'}, bodyChars=${payload.body.length}, existingTags=${payload.tags ? 'yes' : 'no'})`);
+ log(`AI tags: base URL ${baseUrl}`);
+ log(`AI tags: title preview ${JSON.stringify((payload.title || '').slice(0, 120))}`);
+ log(`AI tags: existing tags preview ${JSON.stringify((payload.tags || '').slice(0, 120))}`);
+ log(`AI tags: body preview ${JSON.stringify((payload.body || '').slice(0, 280))}`);
+ const modelId = await detectLmStudioModel(baseUrl, controller.signal);
+ log(`AI tags: using model ${modelId}`);
+ let lastError = '';
+
+ for (const endpoint of endpoints) {
+ const requestBody = {
+ model: modelId,
+ temperature: 0.2,
+ max_tokens: 256,
+ messages: [
+ {
+ role: 'system',
+ content: 'Return only a JSON array that matches the provided schema. Do not include prose, markdown, commentary, or reasoning. The array must contain at least one relevant tag.',
+ },
+ {
+ role: 'user',
+ content: prompt,
+ },
+ ],
+ response_format: {
+ type: 'json_schema',
+ json_schema: {
+ name: 'blog_tags',
+ strict: true,
+ schema: BLOG_TAGS_JSON_SCHEMA,
+ },
+ },
+ };
+
+ let res = await postLmStudio(endpoint, requestBody, controller.signal);
+ log(`AI tags: chat attempt ${endpoint} -> ${res.status}`);
+ let textOnError = '';
+ if (!res.ok) textOnError = await res.text();
+
+ // Structured output is required here. If an endpoint does not support
+ // response_format/json_schema, skip it and try the next endpoint
+ // instead of falling back to unstructured text generation.
+ if (!res.ok && (res.status === 400 || res.status === 422)) {
+ const unsupported = /response_format|json_schema|unsupported|unknown/i.test(textOnError);
+ if (unsupported) {
+ lastError = `Endpoint ${endpoint} does not support structured output.`;
+ log(`AI tags: ${lastError}`);
+ continue;
+ }
+ }
+
+ if (!res.ok) {
+ const text = textOnError || await res.text();
+ const maybeWrongEndpoint = res.status === 404 || res.status === 405 || /not\s*found/i.test(text);
+ if (maybeWrongEndpoint) {
+ lastError = `Endpoint ${endpoint} unavailable (${res.status}).`;
+ log(`AI tags: ${lastError}`);
+ continue;
+ }
+ lastError = `LM Studio request failed (${res.status}) on ${endpoint}: ${text || 'no body'}`;
+ log(`AI tags: ${lastError}`);
+ continue;
+ }
+
+ /** @type {unknown} */
+ const data = await res.json();
+ const content = extractLmStudioMessageContent(data);
+ const parsed = parseTagArray(content);
+ const tags = normalizeGeneratedTags(parsed);
+ if (tags.length === 0) {
+ const fallbackTags = buildFallbackTags(payload);
+ if (fallbackTags.length > 0) {
+ log(`AI tags: LM Studio returned empty structured output, falling back to ${fallbackTags.length} derived tag(s)`);
+ log(`AI tags: fallback tags ${JSON.stringify(fallbackTags)}`);
+ return fallbackTags;
+ }
+ lastError = `LM Studio returned no usable tags from ${endpoint}.`;
+ log(`AI tags: ${lastError}`);
+ continue;
+ }
+ log(`AI tags: success via ${endpoint}, generated ${tags.length} tags`);
+ return tags;
+ }
+
+ throw new Error(lastError || `No LM Studio chat endpoint succeeded. Tried: ${endpoints.join(', ')}`);
+ } catch (err) {
+ if (err && typeof err === 'object' && 'name' in err && err.name === 'AbortError') {
+ log('AI tags: request timed out after 90s');
+ throw new Error('LM Studio request timed out after 90s.');
+ }
+ log(`AI tags: exception ${String(err)}`);
+ throw err;
+ } finally {
+ clearTimeout(timeout);
+ log('AI tags: request finished');
+ }
+}
+
/**
* A tree item representing a single blog post.
* Stores the slug separately so context-menu commands can retrieve it
@@ -145,6 +566,10 @@ function resolveFromNearestNodeModules(pkg, startDir) {
* @param {vscode.ExtensionContext} context
*/
async function activate(context) {
+ output = vscode.window.createOutputChannel('Blog Editor');
+ context.subscriptions.push(output);
+ log(`Blog Editor activated (version ${EXTENSION_VERSION})`);
+
sidebarProvider = new BlogSidebarProvider();
context.subscriptions.push(
vscode.window.registerTreeDataProvider('blog-editor-sidebar', sidebarProvider),
@@ -180,7 +605,9 @@ async function newPost(context) {
}
const now = new Date().toISOString().slice(0, 10);
- const starter = `---\ntitle: "${slug}"\ndate: "${now}"\ntags: []\n---\n\n`;
+ const author = getPreviousAuthor(blogDir);
+ const authorLine = author ? `\nauthor: "${author.replace(/"/g, '\\"')}"` : '';
+ const starter = `---\ntitle: "${slug}"\ndate: "${now}"${authorLine}\ntags: ""\n---\n\n`;
fs.mkdirSync(blogDir, { recursive: true });
fs.writeFileSync(filePath, starter, 'utf-8');
activeBlogDir = blogDir;
@@ -231,9 +658,10 @@ async function ensureBundle() {
const milkdownPath = path.join(DIST_DIR, 'milkdown.js');
const commonCssPath = path.join(DIST_DIR, 'milkdown-common.css');
const themeCssPath = path.join(DIST_DIR, 'milkdown-theme.css');
+ const spellcheckPath = path.join(DIST_DIR, 'spellcheck.js');
// Prefer prebuilt assets in packaged VSIX to avoid requiring esbuild at runtime.
- if (fs.existsSync(milkdownPath) && fs.existsSync(commonCssPath) && fs.existsSync(themeCssPath)) return;
+ if (fs.existsSync(milkdownPath) && fs.existsSync(commonCssPath) && fs.existsSync(themeCssPath) && fs.existsSync(spellcheckPath)) return;
await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: 'Blog Editor: Bundling Milkdown…' },
@@ -253,7 +681,7 @@ async function ensureBundle() {
const crepeDir = path.join(crepePkg, 'lib', 'theme');
const fontLoaders = { '.woff2': 'dataurl', '.woff': 'dataurl', '.ttf': 'dataurl', '.eot': 'dataurl', '.svg': 'dataurl' };
- const [jsResult, cssCommon, cssTheme] = await Promise.all([
+ const [jsResult, cssCommon, cssTheme, spellcheckResult] = await Promise.all([
esbuild.build({
stdin: { contents: `export { Crepe, CrepeFeature } from '@milkdown/crepe';`, resolveDir: rootDir, loader: 'js' },
bundle: true, format: 'esm', platform: 'browser', target: 'es2022', minify: true, write: false,
@@ -266,11 +694,22 @@ async function ensureBundle() {
entryPoints: [path.join(crepeDir, 'frame-dark', 'style.css')],
bundle: true, write: false, loader: fontLoaders,
}),
+ esbuild.build({
+ entryPoints: [path.join(EXT_DIR, 'spellcheck.js')],
+ bundle: true,
+ format: 'esm',
+ platform: 'browser',
+ target: 'es2022',
+ minify: true,
+ write: false,
+ loader: { '.aff': 'text', '.dic': 'text' },
+ }),
]);
fs.writeFileSync(path.join(DIST_DIR, 'milkdown.js'), jsResult.outputFiles[0].contents);
fs.writeFileSync(path.join(DIST_DIR, 'milkdown-common.css'), cssCommon.outputFiles[0].contents);
fs.writeFileSync(path.join(DIST_DIR, 'milkdown-theme.css'), cssTheme.outputFiles[0].contents);
+ fs.writeFileSync(path.join(DIST_DIR, 'spellcheck.js'), spellcheckResult.outputFiles[0].contents);
}
);
}
@@ -330,6 +769,7 @@ async function openEditor(context, initialSlug) {
function buildHtml(webview) {
const nonce = crypto.randomBytes(16).toString('hex');
const milkdownJs = webview.asWebviewUri(vscode.Uri.file(path.join(DIST_DIR, 'milkdown.js')));
+ const spellcheckJs = webview.asWebviewUri(vscode.Uri.file(path.join(DIST_DIR, 'spellcheck.js')));
const commonCss = webview.asWebviewUri(vscode.Uri.file(path.join(DIST_DIR, 'milkdown-common.css')));
const themeCss = webview.asWebviewUri(vscode.Uri.file(path.join(DIST_DIR, 'milkdown-theme.css')));
const csp = webview.cspSource;
@@ -339,6 +779,7 @@ function buildHtml(webview) {
.replace(/\{\{NONCE\}\}/g, nonce)
.replace(/\{\{CSP_SOURCE\}\}/g, csp)
.replace('{{MILKDOWN_JS}}', milkdownJs.toString())
+ .replace('{{SPELLCHECK_JS}}', spellcheckJs.toString())
.replace('{{COMMON_CSS}}', commonCss.toString())
.replace('{{THEME_CSS}}', themeCss.toString());
}
@@ -391,7 +832,7 @@ function revertWebviewImageUris(content, websiteBaseUri) {
/**
* Handles messages from the webview.
- * @param {{ type: string, slug?: string, content?: string, filename?: string, dataBase64?: string, blobUrl?: string }} msg
+ * @param {{ type: string, slug?: string, oldSlug?: string, newSlug?: string, content?: string, filename?: string, dataBase64?: string, blobUrl?: string, title?: string, body?: string, tags?: string }} msg
* @param {string} blogDir
* @param {string} websiteDir
*/
@@ -486,6 +927,28 @@ function handleMessage(msg, blogDir, websiteDir) {
sidebarProvider?.refresh();
break;
}
+ case 'generateTags': {
+ const title = (msg.title || '').trim();
+ const body = (msg.body || '').trim();
+ const tags = (msg.tags || '').trim();
+ log('AI tags: webview requested generation');
+ if (!title && !body) {
+ log('AI tags: rejected request - missing title/body');
+ webview.postMessage({ type: 'error', message: 'Need title or body for tag generation.' });
+ return;
+ }
+ webview.postMessage({ type: 'aiTagsStarted' });
+ generateTagsWithLmStudio({ title, body, tags })
+ .then(generated => {
+ log('AI tags: sending generated tags to webview');
+ webview.postMessage({ type: 'aiTagsGenerated', tags: generated });
+ })
+ .catch(err => {
+ log(`AI tags: generation failed ${String(err)}`);
+ webview.postMessage({ type: 'error', message: `AI tag generation failed: ${String(err)}` });
+ });
+ break;
+ }
case 'ready': {
if (pendingOpenSlug) {
webview.postMessage({ type: 'openSlug', slug: pendingOpenSlug });
diff --git a/tools/blogeditor/package.json b/tools/blogeditor/package.json
index db94c35..ad84860 100644
--- a/tools/blogeditor/package.json
+++ b/tools/blogeditor/package.json
@@ -2,11 +2,18 @@
"name": "blog-editor",
"displayName": "Blog Editor",
"description": "WYSIWYG Milkdown blog post editor for this workspace",
- "version": "0.1.12",
+ "version": "0.1.32",
"publisher": "local",
- "engines": { "vscode": "^1.80.0" },
- "categories": ["Other"],
- "repository": { "type": "git", "url": "https://github.com/local/blog-editor" },
+ "engines": {
+ "vscode": "^1.80.0"
+ },
+ "categories": [
+ "Other"
+ ],
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/local/blog-editor"
+ },
"activationEvents": [],
"main": "./extension.js",
"contributes": {
@@ -79,6 +86,8 @@
}
},
"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"
}
}
diff --git a/tools/blogeditor/spellcheck.js b/tools/blogeditor/spellcheck.js
new file mode 100644
index 0000000..0ecea7d
--- /dev/null
+++ b/tools/blogeditor/spellcheck.js
@@ -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;
+ }
+}
diff --git a/website/scripts/BlogComments.js b/website/scripts/BlogComments.js
new file mode 100644
index 0000000..6492ba3
--- /dev/null
+++ b/website/scripts/BlogComments.js
@@ -0,0 +1,1411 @@
+import MxjsClient, { ClientEvents } from "https://unpkg.com/@litruv/mxjs-lite/dist/mxjs-lite.min.js";
+
+/**
+ * Matrix-backed comments and reactions for static blog posts.
+ */
+export class BlogComments {
+ /** @type {string} */
+ #homeserver;
+
+ /** @type {string} */
+ #roomAlias;
+
+ /** @type {string} */
+ #postSlug;
+
+ /** @type {MxjsClient | null} */
+ #client = null;
+
+ /** @type {string | null} */
+ #roomId = null;
+
+ /** @type {string | null} */
+ #threadRootEventId = null;
+
+ /** @type {HTMLElement | null} */
+ #root = null;
+
+ /** @type {HTMLElement | null} */
+ #statusEl = null;
+
+ /** @type {HTMLElement | null} */
+ #listEl = null;
+
+ /** @type {HTMLElement | null} */
+ #postReactionsEl = null;
+
+ /** @type {HTMLInputElement | null} */
+ #inputEl = null;
+
+ /** @type {HTMLButtonElement | null} */
+ #sendBtn = null;
+
+ /** @type {Map} */
+ #members = new Map();
+
+ /** @type {Map} */
+ #commentEventMap = new Map();
+
+ /** @type {Map} */
+ #reactionEventMap = new Map();
+
+ /** @type {Map} */
+ #ownReactionByTargetAndKey = new Map();
+
+ /** @type {Map} */
+ #reactionChipMap = new Map();
+
+ /** @type {Map} */
+ #reactionBtnMap = new Map();
+
+ /** @type {Set} */
+ #seenEvents = new Set();
+
+ /** @type {boolean} */
+ #isConnected = false;
+
+ /** @type {boolean} */
+ #historyLoaded = false;
+
+ /** @type {boolean} */
+ #hasAutoReconnected = false;
+
+ /** @type {Record[]} */
+ #pendingEvents = [];
+
+ /** @type {string | null} */
+ #displayName = null;
+
+ /** @type {HTMLElement | null} */
+ #identityEl = null;
+
+ /** @type {number | null} */
+ #nickCooldownTimer = null;
+
+ /** @type {{ firstParts: string[], secondParts: string[] } | null} */
+ static #nameParts = null;
+
+ /** @type {Promise | null} */
+ static #namePartsPromise = null;
+
+ /** @type {string[]} */
+ static #reactionChoices = ["👍", "❤️", "🔥", "😂", "👀"];
+
+ /**
+ * @param {{ homeserver: string, roomAlias: string, postSlug: string }} options
+ */
+ constructor(options) {
+ this.#homeserver = options.homeserver;
+ this.#roomAlias = options.roomAlias;
+ this.#postSlug = options.postSlug;
+ }
+
+ /**
+ * Mounts comments UI and starts Matrix connection flow.
+ *
+ * @param {HTMLElement} mountEl
+ * @returns {Promise}
+ */
+ async initialize(mountEl) {
+ this.#root = mountEl;
+ this.#renderShell();
+ await this.#connect();
+ }
+
+ /**
+ * Returns the localStorage key used for Matrix auth session.
+ *
+ * @returns {string}
+ */
+ #sessionKey() {
+ return `mxjs_blog_comments_session_${this.#homeserver}_${this.#roomAlias}`;
+ }
+
+ /**
+ * Returns the localStorage key used to store the user's chosen display name.
+ *
+ * @returns {string}
+ */
+ #customNameKey() {
+ return `mxjs_blog_comments_name_${this.#homeserver}`;
+ }
+
+ /**
+ * Returns the stable thread marker used to identify this post thread root.
+ *
+ * @returns {string}
+ */
+ #threadMarker() {
+ return `[blog-thread:${this.#postSlug}]`;
+ }
+
+ /**
+ * Returns the root body text used when creating a post thread.
+ *
+ * @returns {string}
+ */
+ #threadRootBody() {
+ return `${this.#threadMarker()} /blog/${this.#postSlug}/`;
+ }
+
+ /**
+ * Creates the base comments UI.
+ *
+ * @returns {void}
+ */
+ #renderShell() {
+ if (!this.#root) return;
+
+ const sectionTitle = document.createElement("h2");
+ sectionTitle.className = "blog-comments-title";
+ sectionTitle.textContent = "Comments";
+
+ const status = document.createElement("p");
+ status.className = "blog-comments-status";
+ status.textContent = "Connecting...";
+
+ const postActions = document.createElement("div");
+ postActions.className = "blog-post-reactions-actions";
+ for (const emoji of BlogComments.#reactionChoices) {
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "blog-reaction-btn";
+ btn.setAttribute("aria-label", `React to post with ${emoji}`);
+ btn.addEventListener("click", () => this.#togglePostReaction(emoji));
+
+ const emojiSpan = document.createElement("span");
+ emojiSpan.className = "blog-reaction-btn-emoji";
+ emojiSpan.textContent = emoji;
+
+ const countSpan = document.createElement("span");
+ countSpan.className = "blog-reaction-btn-count is-hidden";
+
+ btn.append(emojiSpan, countSpan);
+ postActions.appendChild(btn);
+ this.#reactionBtnMap.set(emoji, btn);
+ }
+
+ const list = document.createElement("div");
+ list.className = "blog-comments-list";
+ list.setAttribute("aria-live", "polite");
+
+ const identity = document.createElement("div");
+ identity.className = "blog-comments-identity is-hidden";
+
+ const composer = document.createElement("div");
+ composer.className = "blog-comments-composer";
+
+ const input = document.createElement("input");
+ input.className = "blog-comments-input";
+ input.type = "text";
+ input.placeholder = "Write a comment...";
+ input.maxLength = 1000;
+
+ const sendBtn = document.createElement("button");
+ sendBtn.className = "blog-comments-send";
+ sendBtn.type = "button";
+ sendBtn.textContent = "Send";
+
+ composer.append(input, sendBtn);
+
+ this.#root.innerHTML = "";
+ this.#root.append(postActions, sectionTitle, status, list, identity, composer);
+
+ this.#statusEl = status;
+ this.#postReactionsEl = postActions;
+ this.#listEl = list;
+ this.#identityEl = identity;
+ this.#inputEl = input;
+ this.#sendBtn = sendBtn;
+
+ sendBtn.addEventListener("click", () => {
+ this.#sendComment();
+ });
+
+ input.addEventListener("keydown", (event) => {
+ if (event.key !== "Enter" || event.shiftKey) return;
+ event.preventDefault();
+ this.#sendComment();
+ });
+ }
+
+ /**
+ * Renders the "Commenting as [name] ✏️" identity bar.
+ *
+ * @returns {void}
+ */
+ #renderIdentity() {
+ if (!this.#identityEl || !this.#displayName) return;
+
+ this.#identityEl.innerHTML = "";
+ this.#identityEl.classList.remove("is-hidden");
+
+ const label = document.createElement("span");
+ label.className = "blog-comments-identity-label";
+ label.textContent = "Commenting as ";
+
+ const nameSpan = document.createElement("strong");
+ nameSpan.className = "blog-comments-identity-name";
+ nameSpan.textContent = this.#displayName;
+
+ const editBtn = document.createElement("button");
+ editBtn.type = "button";
+ editBtn.className = "blog-comments-identity-edit-btn";
+ editBtn.setAttribute("aria-label", "Edit display name");
+
+ const remaining = this.#nickCooldownRemaining();
+ if (remaining > 0) {
+ editBtn.disabled = true;
+ editBtn.title = `Wait ${remaining}s before changing name again`;
+ editBtn.textContent = `✏️ ${remaining}s`;
+ this.#startCooldownTick(editBtn);
+ } else {
+ editBtn.title = "Edit display name";
+ editBtn.textContent = "✏️";
+ editBtn.addEventListener("click", () => this.#startEditName());
+ }
+
+ this.#identityEl.append(label, nameSpan, editBtn);
+ }
+
+ /**
+ * Ticks the countdown text on the edit button without rebuilding the DOM.
+ *
+ * @param {HTMLButtonElement} editBtn
+ * @returns {void}
+ */
+ #startCooldownTick(editBtn) {
+ if (this.#nickCooldownTimer !== null) clearInterval(this.#nickCooldownTimer);
+ this.#nickCooldownTimer = setInterval(() => {
+ const remaining = this.#nickCooldownRemaining();
+ if (remaining <= 0) {
+ clearInterval(this.#nickCooldownTimer);
+ this.#nickCooldownTimer = null;
+ editBtn.disabled = false;
+ editBtn.title = "Edit display name";
+ editBtn.textContent = "✏️";
+ editBtn.addEventListener("click", () => this.#startEditName());
+ } else {
+ editBtn.textContent = `✏️ ${remaining}s`;
+ editBtn.title = `Wait ${remaining}s before changing name again`;
+ }
+ }, 1000);
+ }
+
+ /**
+ * Returns the localStorage key used to store nick-change timestamp.
+ *
+ * @returns {string}
+ */
+ #nickCooldownKey() {
+ return `mxjs_blog_comments_nick_ts_${this.#homeserver}`;
+ }
+
+ /**
+ * Returns seconds remaining on the nick-change cooldown, or 0 if not cooling down.
+ *
+ * @returns {number}
+ */
+ #nickCooldownRemaining() {
+ const stored = localStorage.getItem(this.#nickCooldownKey());
+ if (!stored) return 0;
+ const ts = Number(stored);
+ if (!ts) return 0;
+ const elapsed = Math.floor((Date.now() - ts) / 1000);
+ return Math.max(0, 60 - elapsed);
+ }
+
+ /**
+ * Replaces the name display with an inline edit input.
+ *
+ * @returns {void}
+ */
+ #startEditName() {
+ if (!this.#identityEl || !this.#displayName) return;
+
+ this.#identityEl.innerHTML = "";
+
+ const label = document.createElement("span");
+ label.className = "blog-comments-identity-label";
+ label.textContent = "Commenting as ";
+
+ const input = document.createElement("input");
+ input.type = "text";
+ input.className = "blog-comments-identity-input";
+ input.value = this.#displayName;
+ input.maxLength = 64;
+ input.setAttribute("aria-label", "Display name");
+
+ const saveBtn = document.createElement("button");
+ saveBtn.type = "button";
+ saveBtn.className = "blog-comments-identity-save-btn";
+ saveBtn.textContent = "Save";
+
+ const confirm = () => {
+ const newName = input.value.trim();
+ console.log("[BlogComments] confirm name edit:", { newName, current: this.#displayName });
+ if (newName && newName !== this.#displayName) {
+ this.#saveDisplayName(newName);
+ } else {
+ this.#renderIdentity();
+ }
+ };
+
+ saveBtn.addEventListener("click", confirm);
+ input.addEventListener("keydown", (event) => {
+ if (event.key === "Enter") { event.preventDefault(); confirm(); }
+ if (event.key === "Escape") { this.#renderIdentity(); }
+ });
+
+ this.#identityEl.append(label, input, saveBtn);
+ input.focus();
+ input.select();
+ }
+
+ /**
+ * Saves a new display name, persists to localStorage, and updates the Matrix profile.
+ *
+ * @param {string} newName
+ * @returns {Promise}
+ */
+ async #saveDisplayName(newName) {
+ if (!this.#client || !newName) return;
+
+ console.log("[BlogComments] saving display name:", newName);
+
+ this.#displayName = newName;
+ localStorage.setItem(this.#customNameKey(), newName);
+
+ const nowTs = Date.now();
+ localStorage.setItem(this.#nickCooldownKey(), String(nowTs));
+ if (this.#nickCooldownTimer !== null) clearInterval(this.#nickCooldownTimer);
+ this.#nickCooldownTimer = null;
+
+ if (this.#client?.userId) {
+ this.#members.set(this.#client.userId, { name: newName });
+ }
+
+ this.#renderIdentity();
+ this.#updateOwnRenderedNames(newName);
+
+ try {
+ const ok = await this.#client.setDisplayName(newName);
+ console.log("[BlogComments] setDisplayName result:", ok);
+ if (!ok) console.warn("[BlogComments] setDisplayName returned falsy — may have been blocked by server");
+ } catch (error) {
+ console.warn("[BlogComments] Failed to update display name", error);
+ }
+ }
+
+ /**
+ * Updates the displayed author name on all comments sent by the current user.
+ *
+ * @param {string} newName
+ * @returns {void}
+ */
+ #updateOwnRenderedNames(newName) {
+ if (!this.#client?.userId) return;
+ for (const entry of this.#commentEventMap.values()) {
+ if (entry.sender === this.#client.userId) {
+ entry.senderEl.textContent = newName;
+ }
+ }
+ }
+
+ /**
+ * Updates connection status text.
+ *
+ * @param {string} text
+ * @param {"idle" | "ok" | "error"} tone
+ * @returns {void}
+ */
+ #setStatus(text, tone = "idle") {
+ if (!this.#statusEl) return;
+ this.#statusEl.textContent = text;
+ this.#statusEl.classList.remove("is-ok", "is-error");
+ if (tone === "ok") this.#statusEl.classList.add("is-ok");
+ if (tone === "error") this.#statusEl.classList.add("is-error");
+ if (!text) {
+ this.#statusEl.classList.add("is-hidden");
+ return;
+ }
+ this.#statusEl.classList.remove("is-hidden");
+ }
+
+ /**
+ * Toggles loading UI state.
+ *
+ * @param {boolean} isLoading
+ * @returns {void}
+ */
+ #setLoading(isLoading) {
+ if (!this.#root) return;
+ this.#root.classList.toggle("is-loading", isLoading);
+ }
+
+ /**
+ * Resets all runtime state so a fresh connection can be established.
+ *
+ * @returns {void}
+ */
+ #resetState() {
+ this.#client = null;
+ this.#roomId = null;
+ this.#threadRootEventId = null;
+ this.#isConnected = false;
+ this.#historyLoaded = false;
+ this.#pendingEvents = [];
+ this.#members = new Map();
+ this.#commentEventMap = new Map();
+ this.#reactionEventMap = new Map();
+ this.#ownReactionByTargetAndKey = new Map();
+ this.#reactionChipMap = new Map();
+ this.#reactionBtnMap = new Map();
+ this.#seenEvents = new Set();
+ this.#hasAutoReconnected = false;
+ this.#renderShell();
+ }
+
+ /**
+ * Opens Matrix session and joins the configured comments room.
+ *
+ * @returns {Promise}
+ */
+ async #connect() {
+ this.#setLoading(true);
+ this.#setStatus("Connecting to Matrix...");
+ this.#setComposerState(true);
+
+ try {
+ this.#client = new MxjsClient({ homeserver: this.#homeserver });
+
+ const restored = await this.#restoreSession();
+ if (!restored) {
+ this.#setStatus("Setting up...");
+ await this.#registerAccount();
+ await this.#ensureDisplayName();
+ }
+
+ const join = await this.#joinCommentsRoom();
+ if (!join?.roomId) throw new Error("Failed to join comments room");
+
+ this.#roomId = join.roomId;
+ await this.#ensureDisplayName();
+ await this.#loadMembers();
+ await this.#ensureThreadRoot();
+ this.#bindEvents();
+ await this.#client.startSync(30000);
+ this.#isConnected = true;
+
+ await this.#loadThreadHistory();
+ await this.#loadHistoricalReactions();
+ this.#setStatus("");
+ this.#setComposerState(false);
+ this.#renderIdentity();
+
+ if (this.#listEl && this.#listEl.children.length === 0) {
+ this.#appendSystemRow("No comments yet. Be the first.");
+ }
+ } catch (error) {
+ console.error("[BlogComments] Connection failed:", error);
+ localStorage.removeItem(this.#sessionKey());
+ this.#setStatus(`Connection failed: ${error.message}`, "error");
+ this.#setComposerState(true);
+ } finally {
+ this.#setLoading(false);
+ }
+ }
+
+ /**
+ * Joins the configured room alias, with a federated fallback path.
+ *
+ * @returns {Promise<{ roomId: string } | null>}
+ */
+ async #joinCommentsRoom() {
+ if (!this.#client) return null;
+
+ const directJoin = await this.#client.joinRoom(this.#roomAlias);
+ if (directJoin?.roomId) return directJoin;
+
+ const resolved = await this.#client.resolveRoomAlias(this.#roomAlias);
+ if (!resolved) return null;
+
+ if (typeof this.#client.joinRoomById !== "function") return null;
+
+ const aliasServer = this.#extractAliasServer(this.#roomAlias);
+ const via = aliasServer ? [aliasServer] : undefined;
+ const byId = await this.#client.joinRoomById(resolved, via ? { via } : {});
+ if (byId?.roomId) return byId;
+
+ return null;
+ }
+
+ /**
+ * Restores saved Matrix auth from localStorage and validates the token is still accepted.
+ *
+ * @returns {Promise}
+ */
+ async #restoreSession() {
+ const raw = localStorage.getItem(this.#sessionKey());
+ if (!raw || !this.#client) return false;
+
+ try {
+ const parsed = JSON.parse(raw);
+ if (!parsed?.accessToken || !parsed?.userId) return false;
+
+ this.#client.accessToken = parsed.accessToken;
+ this.#client.userId = parsed.userId;
+
+ const valid = await this.#validateToken(parsed.accessToken);
+ if (!valid) {
+ if (parsed.username && parsed.password) {
+ console.warn("[BlogComments] Token expired — re-logging in");
+ const login = await this.#client.login(parsed.username, parsed.password);
+ if (login?.accessToken) {
+ localStorage.setItem(this.#sessionKey(), JSON.stringify({
+ ...parsed,
+ accessToken: login.accessToken
+ }));
+ return true;
+ }
+ }
+ console.warn("[BlogComments] Session invalid and no credentials to re-login — re-registering");
+ localStorage.removeItem(this.#sessionKey());
+ this.#client.accessToken = null;
+ this.#client.userId = null;
+ return false;
+ }
+
+ return true;
+ } catch (error) {
+ console.warn("[BlogComments] Failed to restore session", error);
+ localStorage.removeItem(this.#sessionKey());
+ return false;
+ }
+ }
+
+ /**
+ * Registers a new Matrix account with a UUID username and password.
+ *
+ * @returns {Promise}
+ */
+ async #registerAccount() {
+ if (!this.#client) return;
+
+ const username = crypto.randomUUID().replace(/-/g, "").slice(0, 10);
+ const password = crypto.randomUUID();
+
+ const result = await this.#client.register(username, password);
+ if (!result?.accessToken || !result?.userId) {
+ throw new Error("Account registration failed");
+ }
+
+ localStorage.setItem(this.#sessionKey(), JSON.stringify({
+ accessToken: result.accessToken,
+ userId: result.userId,
+ username,
+ password
+ }));
+ }
+
+ /**
+ * Checks whether an access token is still accepted by the homeserver.
+ *
+ * @param {string} accessToken
+ * @returns {Promise}
+ */
+ async #validateToken(accessToken) {
+ try {
+ const response = await fetch(
+ `${this.#homeserver}/_matrix/client/v3/account/whoami`,
+ { headers: { Authorization: `Bearer ${accessToken}` } }
+ );
+ return response.ok;
+ } catch {
+ return false;
+ }
+ }
+
+ /**
+ * Ensures the active guest account has a display name, restoring a saved
+ * custom name or generating a fresh random one if none exists.
+ *
+ * @returns {Promise}
+ */
+ async #ensureDisplayName() {
+ if (!this.#client) return;
+
+ const saved = localStorage.getItem(this.#customNameKey());
+ const displayName = saved || await BlogComments.#generateRandomName();
+ if (!saved) localStorage.setItem(this.#customNameKey(), displayName);
+
+ this.#displayName = displayName;
+ await this.#client.setDisplayName(displayName);
+
+ if (this.#client.userId) {
+ this.#members.set(this.#client.userId, { name: displayName });
+ }
+ }
+
+ /**
+ * Loads random name parts from shared data.
+ *
+ * @returns {Promise}
+ */
+ static async #loadNameParts() {
+ if (this.#nameParts) return;
+ if (this.#namePartsPromise) return this.#namePartsPromise;
+
+ this.#namePartsPromise = (async () => {
+ try {
+ const response = await fetch("/data/nameParts.json");
+ const data = await response.json();
+ this.#nameParts = {
+ firstParts: Array.isArray(data?.firstParts) ? data.firstParts : ["Guest"],
+ secondParts: Array.isArray(data?.secondParts) ? data.secondParts : ["User"]
+ };
+ } catch (error) {
+ console.warn("[BlogComments] Failed to load name parts", error);
+ this.#nameParts = {
+ firstParts: ["Guest"],
+ secondParts: ["User"]
+ };
+ }
+ })();
+
+ return this.#namePartsPromise;
+ }
+
+ /**
+ * Generates a random display name matching existing chat naming style.
+ *
+ * @returns {Promise}
+ */
+ static async #generateRandomName() {
+ await this.#loadNameParts();
+
+ const firstParts = this.#nameParts?.firstParts || ["Guest"];
+ const secondParts = this.#nameParts?.secondParts || ["User"];
+ const first = firstParts[Math.floor(Math.random() * firstParts.length)] || "Guest";
+ const second = secondParts[Math.floor(Math.random() * secondParts.length)] || "User";
+ return `${first}${second}`;
+ }
+
+ /**
+ * Loads joined members for better display names.
+ *
+ * @returns {Promise}
+ */
+ async #loadMembers() {
+ if (!this.#client || !this.#roomId) return;
+
+ try {
+ const url = `${this.#homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(this.#roomId)}/members`;
+ const response = await fetch(url, {
+ headers: { Authorization: `Bearer ${this.#client.accessToken}` }
+ });
+
+ if (!response.ok) {
+ console.warn("[BlogComments] Failed to load members, status", response.status);
+ return;
+ }
+
+ const data = await response.json();
+ const events = Array.isArray(data?.chunk) ? data.chunk : [];
+
+ for (const event of events) {
+ if (event.type !== "m.room.member" || event.content?.membership !== "join") continue;
+ const userId = event.state_key;
+ if (!userId) continue;
+ this.#members.set(userId, {
+ name: event.content?.displayname || this.#extractLocalpart(userId)
+ });
+ }
+
+ console.log("[BlogComments] members map after load", [...this.#members.entries()]);
+ } catch (error) {
+ console.warn("[BlogComments] Failed to load members", error);
+ }
+ }
+
+ /**
+ * Subscribes to Matrix timeline events.
+ *
+ * @returns {void}
+ */
+ #bindEvents() {
+ if (!this.#client || !this.#roomId) return;
+
+ this.#client.on(ClientEvents.MessageCreate, ({ roomId, event }) => {
+ if (roomId !== this.#roomId || !event?.event_id) return;
+ if (!this.#historyLoaded) {
+ this.#pendingEvents.push(event);
+ return;
+ }
+ this.#handleIncomingEvent(event);
+ });
+
+ this.#client.on(ClientEvents.MemberUpdate, ({ roomId, change }) => {
+ if (roomId !== this.#roomId || !change?.userId) return;
+
+ if (change.type === "join" || change.type === "rename") {
+ this.#members.set(change.userId, {
+ name: change.displayName || this.#extractLocalpart(change.userId)
+ });
+ return;
+ }
+
+ if (change.type === "leave" || change.type === "kick" || change.type === "ban") {
+ this.#members.delete(change.userId);
+ }
+ });
+
+ this.#client.on(ClientEvents.MessageDelete, ({ roomId, redacts }) => {
+ if (roomId !== this.#roomId || !redacts) return;
+ this.#handleMessageDelete(redacts);
+ });
+
+ this.#client.on(ClientEvents.ReactionAdd, ({ roomId, reacts, key, event }) => {
+ if (roomId !== this.#roomId || !reacts || !key || !event?.event_id) return;
+ if (this.#seenEvents.has(event.event_id)) return;
+ this.#seenEvents.add(event.event_id);
+ this.#applyReaction(reacts, key, event.sender || "", event.event_id);
+ });
+
+ this.#client.on(ClientEvents.ReactionRemove, ({ roomId, reacts, key, event }) => {
+ if (roomId !== this.#roomId || !reacts || !key || !event?.redacts) return;
+ this.#removeReaction(reacts, key, event.redacts);
+ });
+ }
+
+ /**
+ * Ensures there is exactly one root event for this post thread.
+ *
+ * @returns {Promise}
+ */
+ async #ensureThreadRoot() {
+ if (!this.#client || !this.#roomId) return;
+
+ try {
+ const history = await this.#client.getMessages(this.#roomId, { limit: 250 });
+ const events = Array.isArray(history?.messages) ? history.messages : [];
+ const threadRoot = events.find((event) => this.#isThreadRootEvent(event));
+
+ if (threadRoot?.event_id) {
+ this.#threadRootEventId = threadRoot.event_id;
+ return;
+ }
+
+ const sent = await this.#client.sendMessage(this.#roomId, this.#threadRootBody());
+ if (!sent?.eventId) {
+ throw new Error("Failed to create thread root");
+ }
+
+ this.#threadRootEventId = sent.eventId;
+ } catch (error) {
+ console.warn("[BlogComments] Failed to ensure thread root", error);
+ throw error;
+ }
+ }
+
+ /**
+ * Loads thread replies and hydrates comments. Reactions are loaded separately.
+ *
+ * @returns {Promise}
+ */
+ async #loadThreadHistory() {
+ if (!this.#client || !this.#roomId || !this.#threadRootEventId) return;
+
+ try {
+ const thread = await this.#client.getThreadEvents(this.#roomId, this.#threadRootEventId, { limit: 100 });
+ const historyEvents = Array.isArray(thread?.events) ? thread.events : [];
+
+ const allEvents = [...historyEvents, ...this.#pendingEvents];
+ this.#pendingEvents = [];
+ this.#historyLoaded = true;
+
+ allEvents.sort((a, b) => (a.origin_server_ts || 0) - (b.origin_server_ts || 0));
+
+ for (const event of allEvents) {
+ this.#handleIncomingEvent(event);
+ }
+ } catch (error) {
+ console.warn("[BlogComments] Failed to load thread history", error);
+ this.#historyLoaded = true;
+ }
+ }
+
+ /**
+ * Loads historical reactions on the thread root event.
+ *
+ * @returns {Promise}
+ */
+ async #loadHistoricalReactions() {
+ if (!this.#client || !this.#roomId || !this.#threadRootEventId) return;
+
+ try {
+ const result = await this.#client.getEventRelationsByType(
+ this.#roomId,
+ this.#threadRootEventId,
+ "m.annotation",
+ { limit: 200 }
+ );
+ const events = Array.isArray(result?.events) ? result.events : [];
+ for (const event of events) {
+ if (!event?.event_id || this.#seenEvents.has(event.event_id)) continue;
+ this.#seenEvents.add(event.event_id);
+ const relation = event.content?.["m.relates_to"];
+ if (relation?.event_id && relation?.key) {
+ this.#applyReaction(relation.event_id, relation.key, event.sender || "", event.event_id);
+ }
+ }
+ } catch (error) {
+ console.warn("[BlogComments] Failed to load historical reactions", error);
+ }
+ }
+
+ /**
+ * Routes timeline events to comment or reaction handlers.
+ *
+ * @param {Record} event
+ * @returns {void}
+ */
+ #handleIncomingEvent(event) {
+ if (!event?.event_id || this.#seenEvents.has(event.event_id)) return;
+ this.#seenEvents.add(event.event_id);
+
+ if (event.type === "m.room.message") {
+ if (event.content?.["m.relates_to"]?.rel_type === "m.replace") {
+ this.#handleEditEvent(event);
+ return;
+ }
+ if (this.#isThreadRootEvent(event) && !this.#threadRootEventId) {
+ this.#threadRootEventId = event.event_id;
+ }
+ this.#handleMessageEvent(event);
+ return;
+ }
+
+ if (event.type === "m.reaction") {
+ const relation = event.content?.["m.relates_to"];
+ const targetEventId = relation?.event_id;
+ const key = relation?.key;
+ if (!targetEventId || !key) return;
+ this.#applyReaction(targetEventId, key, event.sender || "", event.event_id);
+ }
+ }
+
+ /**
+ * Processes message events and renders post-scoped comments.
+ *
+ * @param {Record} event
+ * @returns {void}
+ */
+ #handleMessageEvent(event) {
+ const body = event.content?.body;
+ const isReply = this.#isThreadReplyEvent(event);
+ if (typeof body !== "string" || !isReply) return;
+
+ const commentText = body.trim();
+ if (!commentText) return;
+
+ if (!this.#listEl) return;
+ const known = this.#commentEventMap.get(event.event_id);
+ if (known) return;
+
+ this.#removeSystemRows();
+
+ const itemEl = document.createElement("article");
+ itemEl.className = "blog-comment-item";
+ itemEl.dataset.eventId = event.event_id;
+
+ const headerEl = document.createElement("header");
+ headerEl.className = "blog-comment-head";
+
+ const senderEl = document.createElement("strong");
+ senderEl.className = "blog-comment-author";
+ senderEl.textContent = this.#displayNameFor(event.sender || "");
+
+ const handleEl = document.createElement("span");
+ handleEl.className = "blog-comment-handle";
+ handleEl.textContent = event.sender || "";
+
+ const authorEl = document.createElement("div");
+ authorEl.className = "blog-comment-author-group";
+ if (event.sender === "@lit:ruv.wtf") {
+ const badge = document.createElement("img");
+ badge.src = "/logos/16px.png";
+ badge.alt = "";
+ badge.className = "blog-comment-owner-badge";
+ authorEl.appendChild(badge);
+ }
+ authorEl.append(senderEl, handleEl);
+
+ const timeEl = document.createElement("time");
+ timeEl.className = "blog-comment-time";
+ timeEl.dateTime = new Date(event.origin_server_ts || Date.now()).toISOString();
+ timeEl.textContent = this.#formatTime(event.origin_server_ts || Date.now());
+
+ headerEl.append(authorEl, timeEl);
+
+ const bodyEl = document.createElement("p");
+ bodyEl.className = "blog-comment-body";
+ bodyEl.textContent = commentText;
+
+ const isOwn = event.sender === this.#client?.userId;
+ itemEl.append(headerEl, bodyEl);
+
+ if (isOwn) {
+ const actionsEl = document.createElement("footer");
+ actionsEl.className = "blog-comment-actions";
+
+ const editBtn = document.createElement("button");
+ editBtn.type = "button";
+ editBtn.className = "blog-comment-action-btn";
+ editBtn.textContent = "Edit";
+ editBtn.addEventListener("click", () => this.#startEditComment(event.event_id));
+
+ const deleteBtn = document.createElement("button");
+ deleteBtn.type = "button";
+ deleteBtn.className = "blog-comment-action-btn is-danger";
+ deleteBtn.textContent = "Delete";
+ deleteBtn.addEventListener("click", () => this.#deleteComment(event.event_id));
+
+ actionsEl.append(editBtn, deleteBtn);
+ itemEl.appendChild(actionsEl);
+ }
+
+ this.#listEl.appendChild(itemEl);
+
+ this.#commentEventMap.set(event.event_id, {
+ itemEl,
+ senderEl,
+ bodyEl,
+ body: commentText,
+ sender: event.sender || ""
+ });
+ }
+
+ /**
+ * Updates a rendered comment body when an m.replace edit event arrives.
+ *
+ * @param {Record} event
+ * @returns {void}
+ */
+ #handleEditEvent(event) {
+ const targetId = event.content?.["m.relates_to"]?.event_id;
+ const newBody = event.content?.["m.new_content"]?.body;
+ if (!targetId || typeof newBody !== "string") return;
+ const entry = this.#commentEventMap.get(targetId);
+ if (!entry) return;
+ const trimmed = newBody.trim();
+ entry.body = trimmed;
+ entry.bodyEl.textContent = trimmed;
+ }
+
+ /**
+ * Switches a comment into inline-edit mode.
+ *
+ * @param {string} eventId
+ * @returns {void}
+ */
+ #startEditComment(eventId) {
+ const entry = this.#commentEventMap.get(eventId);
+ if (!entry) return;
+
+ const { bodyEl } = entry;
+ const originalText = entry.body;
+
+ bodyEl.style.display = "none";
+
+ const editArea = document.createElement("div");
+ editArea.className = "blog-comment-edit-area";
+
+ const input = document.createElement("input");
+ input.type = "text";
+ input.className = "blog-comment-edit-input";
+ input.value = originalText;
+ input.maxLength = 1000;
+
+ const saveBtn = document.createElement("button");
+ saveBtn.type = "button";
+ saveBtn.className = "blog-comment-action-btn";
+ saveBtn.textContent = "Save";
+
+ const cancelBtn = document.createElement("button");
+ cancelBtn.type = "button";
+ cancelBtn.className = "blog-comment-action-btn";
+ cancelBtn.textContent = "Cancel";
+
+ const cancel = () => {
+ editArea.remove();
+ bodyEl.style.display = "";
+ };
+
+ const save = async () => {
+ const newText = input.value.trim();
+ if (!newText || newText === originalText) { cancel(); return; }
+ saveBtn.disabled = true;
+ cancelBtn.disabled = true;
+ input.disabled = true;
+ await this.#saveEditComment(eventId, newText);
+ entry.body = newText;
+ entry.bodyEl.textContent = newText;
+ editArea.remove();
+ bodyEl.style.display = "";
+ };
+
+ saveBtn.addEventListener("click", save);
+ cancelBtn.addEventListener("click", cancel);
+ input.addEventListener("keydown", (e) => {
+ if (e.key === "Enter") { e.preventDefault(); save(); }
+ if (e.key === "Escape") cancel();
+ });
+
+ editArea.append(input, saveBtn, cancelBtn);
+ bodyEl.insertAdjacentElement("afterend", editArea);
+ input.focus();
+ input.select();
+ }
+
+ /**
+ * Sends an m.replace edit event for an existing comment.
+ *
+ * @param {string} eventId
+ * @param {string} newText
+ * @returns {Promise}
+ */
+ async #saveEditComment(eventId, newText) {
+ if (!this.#client || !this.#roomId) return;
+ const txnId = Date.now();
+ const url = `${this.#homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(this.#roomId)}/send/m.room.message/${txnId}`;
+ try {
+ await fetch(url, {
+ method: "PUT",
+ headers: {
+ Authorization: `Bearer ${this.#client.accessToken}`,
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify({
+ msgtype: "m.text",
+ body: `* ${newText}`,
+ "m.new_content": { msgtype: "m.text", body: newText },
+ "m.relates_to": { rel_type: "m.replace", event_id: eventId }
+ })
+ });
+ } catch (error) {
+ console.warn("[BlogComments] Failed to send edit", error);
+ }
+ }
+
+ /**
+ * Redacts a comment event, removing it from the room timeline.
+ *
+ * @param {string} eventId
+ * @returns {Promise}
+ */
+ async #deleteComment(eventId) {
+ if (!this.#client || !this.#roomId) return;
+ const txnId = Date.now();
+ const url = `${this.#homeserver}/_matrix/client/v3/rooms/${encodeURIComponent(this.#roomId)}/redact/${encodeURIComponent(eventId)}/${txnId}`;
+ try {
+ await fetch(url, {
+ method: "PUT",
+ headers: {
+ Authorization: `Bearer ${this.#client.accessToken}`,
+ "Content-Type": "application/json"
+ },
+ body: "{}"
+ });
+ } catch (error) {
+ console.warn("[BlogComments] Failed to delete comment", error);
+ }
+ }
+
+ /**
+ * Adds or removes the current user's reaction for a target message.
+ *
+ * @param {string} targetEventId
+ * @param {string} key
+ * @returns {Promise}
+ */
+ async #togglePostReaction(key) {
+ if (!this.#client || !this.#roomId || !this.#threadRootEventId || !this.#isConnected) return;
+
+ const ownKey = `${this.#threadRootEventId}|${key}`;
+ const ownReactionEventId = this.#ownReactionByTargetAndKey.get(ownKey);
+
+ try {
+ if (ownReactionEventId) {
+ await this.#client.removeReaction(this.#roomId, ownReactionEventId);
+ return;
+ }
+
+ await this.#client.reactToMessage(this.#roomId, this.#threadRootEventId, key);
+ } catch (error) {
+ console.warn("[BlogComments] Failed to toggle post reaction", error);
+ }
+ }
+
+ /**
+ * Applies a reaction to a rendered comment.
+ *
+ * @param {string} targetEventId
+ * @param {string} key
+ * @param {string} sender
+ * @param {string} reactionEventId
+ * @returns {void}
+ */
+ #applyReaction(targetEventId, key, sender, reactionEventId) {
+ if (!this.#threadRootEventId || targetEventId !== this.#threadRootEventId) return;
+
+ this.#reactionEventMap.set(reactionEventId, { targetEventId, key, sender });
+
+ if (sender && sender === this.#client?.userId) {
+ this.#ownReactionByTargetAndKey.set(`${targetEventId}|${key}`, reactionEventId);
+ }
+
+ const btn = this.#reactionBtnMap.get(key);
+ if (!btn) return;
+
+ const countEl = btn.querySelector(".blog-reaction-btn-count");
+ if (countEl) {
+ const current = Number(countEl.textContent || "0");
+ countEl.textContent = String(current + 1);
+ countEl.classList.remove("is-hidden");
+ }
+
+ if (sender && sender === this.#client?.userId) btn.classList.add("is-own");
+ }
+
+ /**
+ * Removes a reaction from a rendered comment.
+ *
+ * @param {string} targetEventId
+ * @param {string} key
+ * @param {string} reactionEventId
+ * @returns {void}
+ */
+ #removeReaction(targetEventId, key, reactionEventId) {
+ if (!this.#threadRootEventId || targetEventId !== this.#threadRootEventId) return;
+
+ const reactionInfo = this.#reactionEventMap.get(reactionEventId);
+ const wasOwn = reactionInfo?.sender === this.#client?.userId;
+ if (wasOwn) {
+ this.#ownReactionByTargetAndKey.delete(`${targetEventId}|${key}`);
+ }
+ this.#reactionEventMap.delete(reactionEventId);
+
+ const btn = this.#reactionBtnMap.get(key);
+ if (!btn) return;
+
+ if (wasOwn) btn.classList.remove("is-own");
+
+ const countEl = btn.querySelector(".blog-reaction-btn-count");
+ if (countEl) {
+ const count = Number(countEl.textContent || "0") - 1;
+ if (count <= 0) {
+ countEl.textContent = "";
+ countEl.classList.add("is-hidden");
+ } else {
+ countEl.textContent = String(count);
+ }
+ }
+ }
+
+ /**
+ * Removes a previously rendered comment when its message is redacted.
+ *
+ * @param {string} eventId
+ * @returns {void}
+ */
+ #handleMessageDelete(eventId) {
+ // If it's a tracked reaction, route to reaction removal.
+ const reactionInfo = this.#reactionEventMap.get(eventId);
+ if (reactionInfo) {
+ this.#removeReaction(reactionInfo.targetEventId, reactionInfo.key, eventId);
+ return;
+ }
+
+ const rendered = this.#commentEventMap.get(eventId);
+ if (rendered?.itemEl) {
+ rendered.itemEl.remove();
+ }
+ this.#commentEventMap.delete(eventId);
+
+ if (!this.#listEl) return;
+ if (this.#listEl.querySelector(".blog-comment-item")) return;
+
+ this.#removeSystemRows();
+ this.#appendSystemRow("No comments yet. Be the first.");
+ }
+
+ /**
+ * Sends a new comment event.
+ *
+ * @returns {Promise}
+ */
+ async #sendComment() {
+ if (!this.#client || !this.#roomId || !this.#threadRootEventId || !this.#inputEl || !this.#sendBtn || !this.#isConnected) return;
+
+ const text = this.#inputEl.value.trim();
+ if (!text) return;
+
+ this.#setComposerState(true);
+ try {
+ if (this.#displayName) {
+ await this.#client.setDisplayName(this.#displayName);
+ }
+
+ let sent = null;
+
+ if (typeof this.#client.sendThreadReply === "function") {
+ sent = await this.#client.sendThreadReply(this.#roomId, this.#threadRootEventId, text);
+ } else {
+ sent = await this.#client.sendEvent(
+ this.#roomId,
+ "m.room.message",
+ {
+ msgtype: "m.text",
+ body: text,
+ "m.relates_to": {
+ rel_type: "m.thread",
+ event_id: this.#threadRootEventId,
+ is_falling_back: true,
+ "m.in_reply_to": { event_id: this.#threadRootEventId }
+ }
+ }
+ );
+ }
+
+ if (!sent?.eventId) throw new Error("Failed to send comment");
+ this.#inputEl.value = "";
+ this.#hasAutoReconnected = false;
+ } catch (error) {
+ console.error("[BlogComments] Failed to send comment", error);
+
+ if (!this.#hasAutoReconnected) {
+ console.warn("[BlogComments] Send failed — attempting session recovery");
+ this.#hasAutoReconnected = true;
+ localStorage.removeItem(this.#sessionKey());
+ this.#resetState();
+ this.#setStatus("Session expired — reconnecting...");
+ await this.#connect();
+ return;
+ }
+
+ this.#appendSystemRow(`Failed to send comment: ${error.message}`, true);
+ } finally {
+ this.#setComposerState(false);
+ this.#inputEl?.focus();
+ }
+ }
+
+ /**
+ * Enables or disables the composer controls.
+ *
+ * @param {boolean} disabled
+ * @returns {void}
+ */
+ #setComposerState(disabled) {
+ if (this.#inputEl) this.#inputEl.disabled = disabled;
+ if (this.#sendBtn) this.#sendBtn.disabled = disabled;
+ }
+
+ /**
+ * Displays a lightweight system row in the comments list.
+ *
+ * @param {string} text
+ * @param {boolean} isError
+ * @returns {void}
+ */
+ #appendSystemRow(text, isError = false) {
+ if (!this.#listEl) return;
+
+ const row = document.createElement("p");
+ row.className = `blog-comments-system${isError ? " is-error" : ""}`;
+ row.textContent = text;
+ this.#listEl.appendChild(row);
+ }
+
+ /**
+ * Removes previously rendered system rows.
+ *
+ * @returns {void}
+ */
+ #removeSystemRows() {
+ if (!this.#listEl) return;
+ this.#listEl.querySelectorAll(".blog-comments-system").forEach((row) => row.remove());
+ }
+
+ /**
+ * Checks if an event is the dedicated root message for this post thread.
+ *
+ * @param {Record} event
+ * @returns {boolean}
+ */
+ #isThreadRootEvent(event) {
+ return event?.type === "m.room.message" && event?.content?.body === this.#threadRootBody();
+ }
+
+ /**
+ * Checks whether a room message belongs to the current post thread.
+ *
+ * @param {Record} event
+ * @returns {boolean}
+ */
+ #isThreadReplyEvent(event) {
+ if (!this.#threadRootEventId || event?.type !== "m.room.message") return false;
+ const relation = event?.content?.["m.relates_to"];
+ return relation?.rel_type === "m.thread" && relation?.event_id === this.#threadRootEventId;
+ }
+
+ /**
+ * Resolves a display name for a Matrix user id.
+ *
+ * @param {string} userId
+ * @returns {string}
+ */
+ #displayNameFor(userId) {
+ if (!userId) return "Unknown";
+ return this.#members.get(userId)?.name || this.#extractLocalpart(userId);
+ }
+
+ /**
+ * Extracts localpart from Matrix user id.
+ *
+ * @param {string} userId
+ * @returns {string}
+ */
+ #extractLocalpart(userId) {
+ const match = userId.match(/^@([^:]+):/);
+ return match ? match[1] : userId;
+ }
+
+ /**
+ * Extracts alias homeserver name from a room alias.
+ *
+ * @param {string} roomAlias
+ * @returns {string | null}
+ */
+ #extractAliasServer(roomAlias) {
+ const match = roomAlias.match(/^#[^:]+:(.+)$/);
+ return match ? match[1] : null;
+ }
+
+ /**
+ * Formats timestamps for comment headers.
+ *
+ * @param {number} timestamp
+ * @returns {string}
+ */
+ #formatTime(timestamp) {
+ const date = new Date(timestamp);
+ return date.toLocaleString("en-US", {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit"
+ });
+ }
+}
diff --git a/website/scripts/blogPage.js b/website/scripts/blogPage.js
index 17607dc..8806627 100644
--- a/website/scripts/blogPage.js
+++ b/website/scripts/blogPage.js
@@ -1,4 +1,5 @@
import { MarkdownRenderer } from "./MarkdownRenderer.js";
+import { BlogComments } from "./BlogComments.js";
/**
* Decodes markdown payload from a JSON script element.
@@ -65,6 +66,33 @@ function renderStaticBlogPost() {
wireCopyButtons(target);
}
+/**
+ * Mounts Matrix-backed comments/reactions for the current blog post.
+ *
+ * @returns {Promise}
+ */
+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.
*
@@ -200,10 +228,11 @@ function setupPeekSplines() {
*
* @returns {void}
*/
-function initializeBlogPage() {
+async function initializeBlogPage() {
renderStaticBlogPost();
setupScrollTopBar();
setupPeekSplines();
+ await setupBlogComments();
}
if (document.readyState === "loading") {
diff --git a/website/styles/main.css b/website/styles/main.css
index 9db72d4..bd2c098 100644
--- a/website/styles/main.css
+++ b/website/styles/main.css
@@ -940,13 +940,13 @@ body.blog-page {
}
- .blog-post-peek {
- display: none;
- }
- .blog-post-splines {
- display: none;
- }
+.blog-post-peek {
+ display: none;
}
+.blog-post-splines {
+ display: none;
+}
+
.blog-post-title {
@@ -1245,6 +1245,366 @@ body.blog-page {
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 {
display: grid;
gap: 12px;
@@ -1281,6 +1641,14 @@ body.blog-page {
padding: 18px 16px 22px;
border-radius: 12px;
}
+
+ .blog-comments-composer {
+ grid-template-columns: 1fr;
+ }
+
+ .blog-comments-send {
+ height: 36px;
+ }
}
/* ─── HUD ─────────────────────────────────────────────────────────────────────── */