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; + } + +
@@ -366,16 +602,23 @@ 👤 - +
+
+ + +
+
+
No file open
+
@@ -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 ─────────────────────────────────────────────────────────────────────── */