mirror of
https://github.com/litruv/lit.ruv.wtf.git
synced 2026-07-25 03:06:02 +10:00
Compare commits
8 Commits
0834d30e9b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a4926c1368 | |||
| b09a329d62 | |||
| 655d16ea6b | |||
| cf08709151 | |||
| 37d6388244 | |||
| 13fa54401f | |||
| ffe6ab9f90 | |||
| 7091b6844e |
49
package-lock.json
generated
49
package-lock.json
generated
@@ -15,7 +15,9 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@milkdown/crepe": "^7.20.0",
|
"@milkdown/crepe": "^7.20.0",
|
||||||
"clean-css-cli": "^5.6.3",
|
"clean-css-cli": "^5.6.3",
|
||||||
"esbuild": "^0.28.0"
|
"dictionary-en-us": "^2.2.1",
|
||||||
|
"esbuild": "^0.28.0",
|
||||||
|
"nspell": "^2.1.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/helper-string-parser": {
|
"node_modules/@babel/helper-string-parser": {
|
||||||
@@ -2493,6 +2495,17 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dictionary-en-us": {
|
||||||
|
"version": "2.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dictionary-en-us/-/dictionary-en-us-2.2.1.tgz",
|
||||||
|
"integrity": "sha512-Z8mycV0ywTfjbUTi0JZfQHqBZhu4CYFtpf7KluSGpt3xHpFlal2S/hiK50tlPynOtR5K3pG5wCradnz1yxwOHA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "(MIT AND BSD)",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.4.2",
|
"version": "3.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz",
|
||||||
@@ -2766,6 +2779,30 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-buffer": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-extglob": {
|
"node_modules/is-extglob": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
@@ -3830,6 +3867,16 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nspell": {
|
||||||
|
"version": "2.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/nspell/-/nspell-2.1.5.tgz",
|
||||||
|
"integrity": "sha512-PSStyugKMiD9mHmqI/CR5xXrSIGejUXPlo88FBRq5Og1kO5QwQ5Ilu8D8O5I/SHpoS+mibpw6uKA8rd3vXd2Sg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-buffer": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/omggif": {
|
"node_modules/omggif": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz",
|
||||||
|
|||||||
@@ -32,6 +32,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@milkdown/crepe": "^7.20.0",
|
"@milkdown/crepe": "^7.20.0",
|
||||||
"clean-css-cli": "^5.6.3",
|
"clean-css-cli": "^5.6.3",
|
||||||
"esbuild": "^0.28.0"
|
"dictionary-en-us": "^2.2.1",
|
||||||
|
"esbuild": "^0.28.0",
|
||||||
|
"nspell": "^2.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ class StaticBlogGenerator {
|
|||||||
' </header>',
|
' </header>',
|
||||||
'<div class="md-hr" role="separator"><span class="md-hr-line"></span><span class="md-hr-arrow"></span></div>',
|
'<div class="md-hr" role="separator"><span class="md-hr-line"></span><span class="md-hr-arrow"></span></div>',
|
||||||
' <section class="blog-post-content" data-blog-post-content></section>',
|
' <section class="blog-post-content" data-blog-post-content></section>',
|
||||||
|
' <section class="blog-comments" data-blog-comments data-matrix-homeserver="https://chat.ruv.wtf" data-matrix-room="#general:chat.ruv.wtf"></section>',
|
||||||
` <script id="blogPostMarkdown" type="application/json">${markdownJson}</script>`,
|
` <script id="blogPostMarkdown" type="application/json">${markdownJson}</script>`,
|
||||||
'</article>',
|
'</article>',
|
||||||
nextPeek,
|
nextPeek,
|
||||||
@@ -247,14 +248,17 @@ class StaticBlogGenerator {
|
|||||||
'</head>',
|
'</head>',
|
||||||
'<body class="blog-page">',
|
'<body class="blog-page">',
|
||||||
'<nav class="quick-links" aria-label="Primary">',
|
'<nav class="quick-links" aria-label="Primary">',
|
||||||
|
' <div class="quick-links-inner">',
|
||||||
this.renderHeaderLinks(),
|
this.renderHeaderLinks(),
|
||||||
' <a class="quick-link rss-link" href="/rss.xml" aria-label="RSS feed" title="RSS feed">',
|
' <a class="quick-link rss-link" href="/rss.xml" aria-label="RSS feed" title="RSS feed">',
|
||||||
' <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M6.18 15.64a2.18 2.18 0 0 1 2.18 2.18C8.36 19.01 7.38 20 6.18 20C4.98 20 4 19.01 4 17.82a2.18 2.18 0 0 1 2.18-2.18M4 4.44A15.56 15.56 0 0 1 19.56 20h-2.83A12.73 12.73 0 0 0 4 7.27V4.44m0 5.66a9.9 9.9 0 0 1 9.9 9.9h-2.83A7.07 7.07 0 0 0 4 12.93V10.1z"/></svg>',
|
' <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M6.18 15.64a2.18 2.18 0 0 1 2.18 2.18C8.36 19.01 7.38 20 6.18 20C4.98 20 4 19.01 4 17.82a2.18 2.18 0 0 1 2.18-2.18M4 4.44A15.56 15.56 0 0 1 19.56 20h-2.83A12.73 12.73 0 0 0 4 7.27V4.44m0 5.66a9.9 9.9 0 0 1 9.9 9.9h-2.83A7.07 7.07 0 0 0 4 12.93V10.1z"/></svg>',
|
||||||
' RSS',
|
' RSS',
|
||||||
' </a>',
|
' </a>',
|
||||||
|
' </div>',
|
||||||
'</nav>',
|
'</nav>',
|
||||||
this.renderScrollTopBar(),
|
this.renderScrollTopBar(),
|
||||||
bodyHtml,
|
bodyHtml,
|
||||||
|
this.renderFooter(),
|
||||||
' <script type="module" src="/scripts/blogPage.js"></script>',
|
' <script type="module" src="/scripts/blogPage.js"></script>',
|
||||||
'</body>',
|
'</body>',
|
||||||
'</html>'
|
'</html>'
|
||||||
@@ -274,16 +278,41 @@ class StaticBlogGenerator {
|
|||||||
renderScrollTopBar() {
|
renderScrollTopBar() {
|
||||||
return [
|
return [
|
||||||
'<header class="blog-scroll-topbar" data-blog-scroll-topbar>',
|
'<header class="blog-scroll-topbar" data-blog-scroll-topbar>',
|
||||||
' <a class="blog-scroll-logo-link" href="/" aria-label="Back to main site">',
|
' <div class="blog-scroll-topbar-inner">',
|
||||||
' <img src="/logos/LogoFull.svg" alt="lit.ruv.wtf" class="blog-scroll-logo" />',
|
' <a class="blog-scroll-logo-link" href="/" aria-label="Back to main site">',
|
||||||
' </a>',
|
' <img src="/logos/LogoFull.svg" alt="lit.ruv.wtf" class="blog-scroll-logo" />',
|
||||||
' <nav class="blog-scroll-links" aria-label="Scrolled navigation">',
|
' </a>',
|
||||||
|
' <nav class="blog-scroll-links" aria-label="Scrolled navigation">',
|
||||||
this.renderHeaderLinks(),
|
this.renderHeaderLinks(),
|
||||||
' </nav>',
|
' </nav>',
|
||||||
|
' </div>',
|
||||||
'</header>'
|
'</header>'
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
renderFooter() {
|
||||||
|
return [
|
||||||
|
'<footer class="blog-footer">',
|
||||||
|
' <div class="blog-footer-inner">',
|
||||||
|
' <a href="/" class="blog-footer-logo" aria-label="Back to main site">',
|
||||||
|
' <img src="/logos/LogoFull.svg" alt="lit.ruv.wtf" class="blog-footer-logo-img" />',
|
||||||
|
' </a>',
|
||||||
|
' <div class="blog-footer-links">',
|
||||||
|
this.renderHeaderLinks(),
|
||||||
|
' <a class="quick-link rss-link" href="/rss.xml" aria-label="RSS feed" title="RSS feed">',
|
||||||
|
' <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M6.18 15.64a2.18 2.18 0 0 1 2.18 2.18C8.36 19.01 7.38 20 6.18 20C4.98 20 4 19.01 4 17.82a2.18 2.18 0 0 1 2.18-2.18M4 4.44A15.56 15.56 0 0 1 19.56 20h-2.83A12.73 12.73 0 0 0 4 7.27V4.44m0 5.66a9.9 9.9 0 0 1 9.9 9.9h-2.83A7.07 7.07 0 0 0 4 12.93V10.1z"/></svg>',
|
||||||
|
' RSS',
|
||||||
|
' </a>',
|
||||||
|
' </div>',
|
||||||
|
` <p class="blog-footer-copy">© ${new Date().getFullYear()} lit.ruv.wtf</p>`,
|
||||||
|
' </div>',
|
||||||
|
'</footer>',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
|
|||||||
BIN
tools/blogeditor/blog-editor.vsix
Normal file
BIN
tools/blogeditor/blog-editor.vsix
Normal file
Binary file not shown.
@@ -50,6 +50,11 @@ const FONT_LOADERS = {
|
|||||||
'.svg': 'dataurl',
|
'.svg': 'dataurl',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TEXT_LOADERS = {
|
||||||
|
'.aff': 'text',
|
||||||
|
'.dic': 'text',
|
||||||
|
};
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
fs.mkdirSync(DIST_DIR, { recursive: true });
|
fs.mkdirSync(DIST_DIR, { recursive: true });
|
||||||
|
|
||||||
@@ -77,6 +82,20 @@ async function main() {
|
|||||||
fs.writeFileSync(path.join(DIST_DIR, 'milkdown-theme.css'), cssTheme.outputFiles[0].contents);
|
fs.writeFileSync(path.join(DIST_DIR, 'milkdown-theme.css'), cssTheme.outputFiles[0].contents);
|
||||||
console.log(`✓ milkdown-theme.css ${(cssTheme.outputFiles[0].contents.byteLength / 1024).toFixed(0)} KB`);
|
console.log(`✓ milkdown-theme.css ${(cssTheme.outputFiles[0].contents.byteLength / 1024).toFixed(0)} KB`);
|
||||||
|
|
||||||
|
console.log('⏳ Bundling spellcheck JS…');
|
||||||
|
const spellcheckJs = await esbuild.build({
|
||||||
|
entryPoints: [path.join(ROOT, 'spellcheck.js')],
|
||||||
|
bundle: true,
|
||||||
|
format: 'esm',
|
||||||
|
platform: 'browser',
|
||||||
|
target: 'es2022',
|
||||||
|
minify: true,
|
||||||
|
write: false,
|
||||||
|
loader: TEXT_LOADERS,
|
||||||
|
});
|
||||||
|
fs.writeFileSync(path.join(DIST_DIR, 'spellcheck.js'), spellcheckJs.outputFiles[0].contents);
|
||||||
|
console.log(`✓ spellcheck.js ${(spellcheckJs.outputFiles[0].contents.byteLength / 1024).toFixed(0)} KB`);
|
||||||
|
|
||||||
console.log('\n✓ dist/ ready — run: npx @vscode/vsce package --out blog-editor.vsix --no-dependencies');
|
console.log('\n✓ dist/ ready — run: npx @vscode/vsce package --out blog-editor.vsix --no-dependencies');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
44
tools/blogeditor/bump-version.js
Normal file
44
tools/blogeditor/bump-version.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const PACKAGE_JSON_PATH = path.join(__dirname, 'package.json');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a semantic version string and bumps the patch number.
|
||||||
|
* @param {string} version
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function bumpPatchVersion(version) {
|
||||||
|
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`Invalid version format: "${version}". Expected x.y.z`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const major = Number(match[1]);
|
||||||
|
const minor = Number(match[2]);
|
||||||
|
const patch = Number(match[3]) + 1;
|
||||||
|
return `${major}.${minor}.${patch}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads package.json, increments patch version, and writes the updated file.
|
||||||
|
*/
|
||||||
|
function main() {
|
||||||
|
const raw = fs.readFileSync(PACKAGE_JSON_PATH, 'utf8');
|
||||||
|
const pkg = JSON.parse(raw);
|
||||||
|
|
||||||
|
if (typeof pkg.version !== 'string') {
|
||||||
|
throw new Error('package.json is missing a valid string version field.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = pkg.version;
|
||||||
|
const next = bumpPatchVersion(previous);
|
||||||
|
pkg.version = next;
|
||||||
|
|
||||||
|
fs.writeFileSync(PACKAGE_JSON_PATH, `${JSON.stringify(pkg, null, 4)}\n`, 'utf8');
|
||||||
|
console.log(`[blog-editor] version bumped: ${previous} -> ${next}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -4,10 +4,12 @@ const vscode = require('vscode');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
const extensionManifest = require('./package.json');
|
||||||
|
|
||||||
const EXT_DIR = __dirname;
|
const EXT_DIR = __dirname;
|
||||||
const DIST_DIR = path.join(EXT_DIR, 'dist');
|
const DIST_DIR = path.join(EXT_DIR, 'dist');
|
||||||
const ROOT_DIR = path.resolve(EXT_DIR, '..');
|
const ROOT_DIR = path.resolve(EXT_DIR, '..');
|
||||||
|
const EXTENSION_VERSION = extensionManifest.version;
|
||||||
|
|
||||||
/** @type {vscode.WebviewPanel | undefined} */
|
/** @type {vscode.WebviewPanel | undefined} */
|
||||||
let panel;
|
let panel;
|
||||||
@@ -21,6 +23,26 @@ let pendingOpenSlug;
|
|||||||
/** @type {BlogSidebarProvider | undefined} */
|
/** @type {BlogSidebarProvider | undefined} */
|
||||||
let sidebarProvider;
|
let sidebarProvider;
|
||||||
|
|
||||||
|
/** @type {vscode.OutputChannel | undefined} */
|
||||||
|
let output;
|
||||||
|
|
||||||
|
const DEFAULT_LM_STUDIO_BASE_URL = 'http://127.0.0.1:1234';
|
||||||
|
const LM_STUDIO_CHAT_ENDPOINTS = ['/v1/chat/completions'];
|
||||||
|
const LM_STUDIO_MODELS_ENDPOINTS = ['/api/v1/models', '/v1/models'];
|
||||||
|
const BLOG_TAGS_JSON_SCHEMA = {
|
||||||
|
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
||||||
|
title: 'Blog Tags',
|
||||||
|
type: 'array',
|
||||||
|
maxItems: 20,
|
||||||
|
uniqueItems: true,
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
minLength: 1,
|
||||||
|
maxLength: 32,
|
||||||
|
pattern: '^[a-z0-9]+(-[a-z0-9]+)*$',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @implements {vscode.TreeDataProvider<vscode.TreeItem>}
|
* @implements {vscode.TreeDataProvider<vscode.TreeItem>}
|
||||||
*/
|
*/
|
||||||
@@ -84,7 +106,7 @@ class BlogSidebarProvider {
|
|||||||
/**
|
/**
|
||||||
* Parses YAML-ish frontmatter from a markdown file.
|
* Parses YAML-ish frontmatter from a markdown file.
|
||||||
* @param {string} filePath
|
* @param {string} filePath
|
||||||
* @returns {{ title?: string, date?: string }}
|
* @returns {{ title?: string, date?: string, author?: string, tags?: string }}
|
||||||
*/
|
*/
|
||||||
function parseFrontmatterFromFile(filePath) {
|
function parseFrontmatterFromFile(filePath) {
|
||||||
try {
|
try {
|
||||||
@@ -101,6 +123,405 @@ function parseFrontmatterFromFile(filePath) {
|
|||||||
} catch { return {}; }
|
} catch { return {}; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} value
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function parseDateToMs(value) {
|
||||||
|
if (!value) return 0;
|
||||||
|
const ts = Date.parse(value);
|
||||||
|
return Number.isFinite(ts) ? ts : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets best previous author by most recent post date (fallback: file mtime).
|
||||||
|
* @param {string} blogDir
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function getPreviousAuthor(blogDir) {
|
||||||
|
if (!fs.existsSync(blogDir)) return '';
|
||||||
|
const entries = fs.readdirSync(blogDir)
|
||||||
|
.filter(f => f.endsWith('.md') && !f.startsWith('.'))
|
||||||
|
.map(file => {
|
||||||
|
const filePath = path.join(blogDir, file);
|
||||||
|
const meta = parseFrontmatterFromFile(filePath);
|
||||||
|
const mtimeMs = fs.statSync(filePath).mtimeMs;
|
||||||
|
return { author: (meta.author || '').trim(), dateMs: parseDateToMs(meta.date || ''), mtimeMs };
|
||||||
|
})
|
||||||
|
.filter(item => item.author)
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (a.dateMs !== b.dateMs) return b.dateMs - a.dateMs;
|
||||||
|
return b.mtimeMs - a.mtimeMs;
|
||||||
|
});
|
||||||
|
return entries[0]?.author || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} raw
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
function parseTagArray(raw) {
|
||||||
|
if (typeof raw !== 'string') {
|
||||||
|
if (raw && typeof raw === 'object' && Array.isArray(raw.tags)) {
|
||||||
|
return raw.tags
|
||||||
|
.map(v => String(v).trim().replace(/^#+/, '').toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleaned = raw
|
||||||
|
.replace(/^```(?:json)?/i, '')
|
||||||
|
.replace(/```$/i, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if (cleaned.startsWith('{') && cleaned.endsWith('}')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(cleaned);
|
||||||
|
if (parsed && typeof parsed === 'object' && Array.isArray(parsed.tags)) {
|
||||||
|
return parsed.tags
|
||||||
|
.map(v => String(v).trim().replace(/^#+/, '').toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to array and split parsing.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = cleaned.indexOf('[');
|
||||||
|
const end = cleaned.lastIndexOf(']');
|
||||||
|
if (start >= 0 && end > start) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(cleaned.slice(start, end + 1));
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed
|
||||||
|
.map(v => String(v).trim().replace(/^#+/, '').toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to comma/newline split.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleaned
|
||||||
|
.split(/[\n,]/)
|
||||||
|
.map(v => v.replace(/^[\s\-•*#]+/, '').trim().toLowerCase())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string[]} tags
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
function normalizeGeneratedTags(tags) {
|
||||||
|
/** @type {string[]} */
|
||||||
|
const out = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const tag of tags) {
|
||||||
|
const clean = tag
|
||||||
|
.replace(/^#+/, '')
|
||||||
|
.replace(/["'`]/g, '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (!clean || seen.has(clean)) continue;
|
||||||
|
seen.add(clean);
|
||||||
|
out.push(clean);
|
||||||
|
if (out.length >= 12) break;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FALLBACK_TAG_RULES = [
|
||||||
|
{ tag: 'unreal-engine', patterns: [/\bunreal engine\b/i, /\bue\b/i] },
|
||||||
|
{ tag: 'game-development', patterns: [/\bgame dev\b/i, /\bgame development\b/i, /\bgamedev\b/i] },
|
||||||
|
{ tag: 'programming', patterns: [/\bprogramming\b/i, /\bdeveloper\b/i, /\bdevelopment\b/i] },
|
||||||
|
{ tag: '3d-art', patterns: [/\b3d art\b/i, /\b3d\b/i] },
|
||||||
|
{ tag: 'animation', patterns: [/\banimation\b/i, /\banimations\b/i] },
|
||||||
|
{ tag: 'systems-design', patterns: [/\bsystems design\b/i, /\binteraction systems\b/i, /\bsystems-heavy\b/i] },
|
||||||
|
{ tag: 'tools', patterns: [/\btools\b/i, /\btooling\b/i] },
|
||||||
|
{ tag: 'development-logs', patterns: [/\bdevelopment logs\b/i, /\bdev logs\b/i, /\blog\b/i] },
|
||||||
|
{ tag: 'embedded-devices', patterns: [/\bembedded devices\b/i, /\bembedded\b/i] },
|
||||||
|
{ tag: 'hardware', patterns: [/\bhardware\b/i, /\baudio gear\b/i] },
|
||||||
|
{ tag: 'ui-frameworks', patterns: [/\bui frameworks\b/i, /\bui framework\b/i] },
|
||||||
|
{ tag: 'modding', patterns: [/\bmodding\b/i, /\bmodding workflows\b/i] },
|
||||||
|
{ tag: 'prototypes', patterns: [/\bprototypes\b/i, /\bprototype\b/i] },
|
||||||
|
{ tag: 'experiments', patterns: [/\bexperiments\b/i, /\bexperiment\b/i] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const FALLBACK_TAG_STOP_WORDS = new Set([
|
||||||
|
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'blog', 'both', 'but', 'by', 'for',
|
||||||
|
'from', 'here', 'into', 'its', 'just', 'make', 'more', 'onto', 'post', 'that',
|
||||||
|
'the', 'their', 'this', 'those', 'through', 'use', 'useful', 'using', 'welcome',
|
||||||
|
'with', 'worth', 'you', 'your',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ title: string, body: string, tags: string }} payload
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
function buildFallbackTags(payload) {
|
||||||
|
const sourceText = `${payload.title || ''}\n${payload.body || ''}`;
|
||||||
|
const fallback = normalizeGeneratedTags(parseTagArray(payload.tags || ''));
|
||||||
|
const seen = new Set(fallback);
|
||||||
|
|
||||||
|
for (const rule of FALLBACK_TAG_RULES) {
|
||||||
|
if (seen.has(rule.tag)) continue;
|
||||||
|
if (rule.patterns.some(pattern => pattern.test(sourceText))) {
|
||||||
|
fallback.push(rule.tag);
|
||||||
|
seen.add(rule.tag);
|
||||||
|
}
|
||||||
|
if (fallback.length >= 8) return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleTokens = (payload.title || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.match(/[a-z0-9]+/g) || [];
|
||||||
|
for (const token of titleTokens) {
|
||||||
|
if (token.length < 4 || FALLBACK_TAG_STOP_WORDS.has(token) || seen.has(token)) continue;
|
||||||
|
fallback.push(token);
|
||||||
|
seen.add(token);
|
||||||
|
if (fallback.length >= 8) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeGeneratedTags(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} message
|
||||||
|
*/
|
||||||
|
function log(message) {
|
||||||
|
const ts = new Date().toISOString();
|
||||||
|
output?.appendLine(`[${ts}] ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string | undefined} urlOrBase
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function resolveLmStudioBaseUrl(urlOrBase) {
|
||||||
|
const raw = (urlOrBase || '').trim();
|
||||||
|
if (!raw) return DEFAULT_LM_STUDIO_BASE_URL;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(raw);
|
||||||
|
parsed.search = '';
|
||||||
|
parsed.hash = '';
|
||||||
|
const pathname = parsed.pathname || '/';
|
||||||
|
const cut = pathname.search(/\/(api\/v1|v1)\b/i);
|
||||||
|
parsed.pathname = cut >= 0 ? pathname.slice(0, cut) || '/' : pathname;
|
||||||
|
return parsed.toString().replace(/\/$/, '');
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_LM_STUDIO_BASE_URL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} baseUrl
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
function buildChatEndpointCandidates(baseUrl) {
|
||||||
|
return LM_STUDIO_CHAT_ENDPOINTS.map(p => `${baseUrl}${p}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {unknown} data
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function extractLmStudioMessageContent(data) {
|
||||||
|
if (!data || typeof data !== 'object') return '';
|
||||||
|
const obj = /** @type {Record<string, unknown>} */ (data);
|
||||||
|
|
||||||
|
const choices = Array.isArray(obj.choices) ? obj.choices : [];
|
||||||
|
for (const choice of choices) {
|
||||||
|
if (!choice || typeof choice !== 'object') continue;
|
||||||
|
const c = /** @type {Record<string, unknown>} */ (choice);
|
||||||
|
const message = c.message;
|
||||||
|
if (message && typeof message === 'object') {
|
||||||
|
const messageObj = /** @type {Record<string, unknown>} */ (message);
|
||||||
|
const content = messageObj.content;
|
||||||
|
if (typeof content === 'string' && content.trim()) return content;
|
||||||
|
const reasoningContent = messageObj.reasoning_content;
|
||||||
|
if (typeof reasoningContent === 'string' && reasoningContent.trim()) return reasoningContent;
|
||||||
|
}
|
||||||
|
if (typeof c.text === 'string' && c.text.trim()) return c.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof obj.output_text === 'string' && obj.output_text.trim()) return obj.output_text;
|
||||||
|
if (typeof obj.content === 'string' && obj.content.trim()) return obj.content;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} baseUrl
|
||||||
|
* @param {AbortSignal} signal
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
async function detectLmStudioModel(baseUrl, signal) {
|
||||||
|
for (const endpointPath of LM_STUDIO_MODELS_ENDPOINTS) {
|
||||||
|
const endpoint = `${baseUrl}${endpointPath}`;
|
||||||
|
try {
|
||||||
|
log(`AI tags: probing models endpoint ${endpoint}`);
|
||||||
|
const res = await fetch(endpoint, { method: 'GET', signal });
|
||||||
|
log(`AI tags: models endpoint status ${res.status} (${endpoint})`);
|
||||||
|
if (!res.ok) continue;
|
||||||
|
/** @type {{ data?: Array<{ id?: string, state?: string, loaded?: boolean }> }} */
|
||||||
|
const data = await res.json();
|
||||||
|
const models = Array.isArray(data?.data) ? data.data : [];
|
||||||
|
const loaded = models.find(m => m && typeof m.id === 'string' && (m.loaded === true || String(m.state || '').toLowerCase() === 'loaded'));
|
||||||
|
if (loaded?.id) {
|
||||||
|
log(`AI tags: selected loaded model ${loaded.id}`);
|
||||||
|
return loaded.id;
|
||||||
|
}
|
||||||
|
const first = models.find(m => m && typeof m.id === 'string');
|
||||||
|
if (first?.id) {
|
||||||
|
log(`AI tags: selected first model ${first.id}`);
|
||||||
|
return first.id;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Try next endpoint variant.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log('AI tags: could not detect model from API, using fallback local-model');
|
||||||
|
return 'local-model';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} endpoint
|
||||||
|
* @param {Record<string, unknown>} body
|
||||||
|
* @param {AbortSignal} signal
|
||||||
|
* @returns {Promise<Response>}
|
||||||
|
*/
|
||||||
|
async function postLmStudio(endpoint, body, signal) {
|
||||||
|
const payload = { ...body, stream: false };
|
||||||
|
return fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
signal,
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates tags from LM Studio OpenAI-compatible endpoint.
|
||||||
|
* @param {{ title: string, body: string, tags: string }} payload
|
||||||
|
* @returns {Promise<string[]>}
|
||||||
|
*/
|
||||||
|
async function generateTagsWithLmStudio(payload) {
|
||||||
|
const configuredUrl = process.env.BLOG_EDITOR_LM_STUDIO_URL;
|
||||||
|
const baseUrl = resolveLmStudioBaseUrl(configuredUrl);
|
||||||
|
const endpoints = buildChatEndpointCandidates(baseUrl);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 90000);
|
||||||
|
|
||||||
|
const prompt = [
|
||||||
|
'Generate 3 to 8 relevant blog tags.',
|
||||||
|
'Return at least 1 tag even if the post is sparse.',
|
||||||
|
'Keep any existing tag if it is still relevant.',
|
||||||
|
'Prefer concrete topical tags over generic filler.',
|
||||||
|
`Title: ${payload.title || '(untitled)'}`,
|
||||||
|
`Existing tags: ${payload.tags || '(none)'}`,
|
||||||
|
'Body:',
|
||||||
|
(payload.body || '').slice(0, 6000),
|
||||||
|
].join('\n\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
log(`AI tags: request started (version=${EXTENSION_VERSION}, title=${payload.title ? 'yes' : 'no'}, bodyChars=${payload.body.length}, existingTags=${payload.tags ? 'yes' : 'no'})`);
|
||||||
|
log(`AI tags: base URL ${baseUrl}`);
|
||||||
|
log(`AI tags: title preview ${JSON.stringify((payload.title || '').slice(0, 120))}`);
|
||||||
|
log(`AI tags: existing tags preview ${JSON.stringify((payload.tags || '').slice(0, 120))}`);
|
||||||
|
log(`AI tags: body preview ${JSON.stringify((payload.body || '').slice(0, 280))}`);
|
||||||
|
const modelId = await detectLmStudioModel(baseUrl, controller.signal);
|
||||||
|
log(`AI tags: using model ${modelId}`);
|
||||||
|
let lastError = '';
|
||||||
|
|
||||||
|
for (const endpoint of endpoints) {
|
||||||
|
const requestBody = {
|
||||||
|
model: modelId,
|
||||||
|
temperature: 0.2,
|
||||||
|
max_tokens: 256,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: 'Return only a JSON array that matches the provided schema. Do not include prose, markdown, commentary, or reasoning. The array must contain at least one relevant tag.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: prompt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
response_format: {
|
||||||
|
type: 'json_schema',
|
||||||
|
json_schema: {
|
||||||
|
name: 'blog_tags',
|
||||||
|
strict: true,
|
||||||
|
schema: BLOG_TAGS_JSON_SCHEMA,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let res = await postLmStudio(endpoint, requestBody, controller.signal);
|
||||||
|
log(`AI tags: chat attempt ${endpoint} -> ${res.status}`);
|
||||||
|
let textOnError = '';
|
||||||
|
if (!res.ok) textOnError = await res.text();
|
||||||
|
|
||||||
|
// Structured output is required here. If an endpoint does not support
|
||||||
|
// response_format/json_schema, skip it and try the next endpoint
|
||||||
|
// instead of falling back to unstructured text generation.
|
||||||
|
if (!res.ok && (res.status === 400 || res.status === 422)) {
|
||||||
|
const unsupported = /response_format|json_schema|unsupported|unknown/i.test(textOnError);
|
||||||
|
if (unsupported) {
|
||||||
|
lastError = `Endpoint ${endpoint} does not support structured output.`;
|
||||||
|
log(`AI tags: ${lastError}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = textOnError || await res.text();
|
||||||
|
const maybeWrongEndpoint = res.status === 404 || res.status === 405 || /not\s*found/i.test(text);
|
||||||
|
if (maybeWrongEndpoint) {
|
||||||
|
lastError = `Endpoint ${endpoint} unavailable (${res.status}).`;
|
||||||
|
log(`AI tags: ${lastError}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lastError = `LM Studio request failed (${res.status}) on ${endpoint}: ${text || 'no body'}`;
|
||||||
|
log(`AI tags: ${lastError}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {unknown} */
|
||||||
|
const data = await res.json();
|
||||||
|
const content = extractLmStudioMessageContent(data);
|
||||||
|
const parsed = parseTagArray(content);
|
||||||
|
const tags = normalizeGeneratedTags(parsed);
|
||||||
|
if (tags.length === 0) {
|
||||||
|
const fallbackTags = buildFallbackTags(payload);
|
||||||
|
if (fallbackTags.length > 0) {
|
||||||
|
log(`AI tags: LM Studio returned empty structured output, falling back to ${fallbackTags.length} derived tag(s)`);
|
||||||
|
log(`AI tags: fallback tags ${JSON.stringify(fallbackTags)}`);
|
||||||
|
return fallbackTags;
|
||||||
|
}
|
||||||
|
lastError = `LM Studio returned no usable tags from ${endpoint}.`;
|
||||||
|
log(`AI tags: ${lastError}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
log(`AI tags: success via ${endpoint}, generated ${tags.length} tags`);
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(lastError || `No LM Studio chat endpoint succeeded. Tried: ${endpoints.join(', ')}`);
|
||||||
|
} catch (err) {
|
||||||
|
if (err && typeof err === 'object' && 'name' in err && err.name === 'AbortError') {
|
||||||
|
log('AI tags: request timed out after 90s');
|
||||||
|
throw new Error('LM Studio request timed out after 90s.');
|
||||||
|
}
|
||||||
|
log(`AI tags: exception ${String(err)}`);
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
log('AI tags: request finished');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A tree item representing a single blog post.
|
* A tree item representing a single blog post.
|
||||||
* Stores the slug separately so context-menu commands can retrieve it
|
* Stores the slug separately so context-menu commands can retrieve it
|
||||||
@@ -145,6 +566,10 @@ function resolveFromNearestNodeModules(pkg, startDir) {
|
|||||||
* @param {vscode.ExtensionContext} context
|
* @param {vscode.ExtensionContext} context
|
||||||
*/
|
*/
|
||||||
async function activate(context) {
|
async function activate(context) {
|
||||||
|
output = vscode.window.createOutputChannel('Blog Editor');
|
||||||
|
context.subscriptions.push(output);
|
||||||
|
log(`Blog Editor activated (version ${EXTENSION_VERSION})`);
|
||||||
|
|
||||||
sidebarProvider = new BlogSidebarProvider();
|
sidebarProvider = new BlogSidebarProvider();
|
||||||
context.subscriptions.push(
|
context.subscriptions.push(
|
||||||
vscode.window.registerTreeDataProvider('blog-editor-sidebar', sidebarProvider),
|
vscode.window.registerTreeDataProvider('blog-editor-sidebar', sidebarProvider),
|
||||||
@@ -180,7 +605,9 @@ async function newPost(context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString().slice(0, 10);
|
const now = new Date().toISOString().slice(0, 10);
|
||||||
const starter = `---\ntitle: "${slug}"\ndate: "${now}"\ntags: []\n---\n\n`;
|
const author = getPreviousAuthor(blogDir);
|
||||||
|
const authorLine = author ? `\nauthor: "${author.replace(/"/g, '\\"')}"` : '';
|
||||||
|
const starter = `---\ntitle: "${slug}"\ndate: "${now}"${authorLine}\ntags: ""\n---\n\n`;
|
||||||
fs.mkdirSync(blogDir, { recursive: true });
|
fs.mkdirSync(blogDir, { recursive: true });
|
||||||
fs.writeFileSync(filePath, starter, 'utf-8');
|
fs.writeFileSync(filePath, starter, 'utf-8');
|
||||||
activeBlogDir = blogDir;
|
activeBlogDir = blogDir;
|
||||||
@@ -231,9 +658,10 @@ async function ensureBundle() {
|
|||||||
const milkdownPath = path.join(DIST_DIR, 'milkdown.js');
|
const milkdownPath = path.join(DIST_DIR, 'milkdown.js');
|
||||||
const commonCssPath = path.join(DIST_DIR, 'milkdown-common.css');
|
const commonCssPath = path.join(DIST_DIR, 'milkdown-common.css');
|
||||||
const themeCssPath = path.join(DIST_DIR, 'milkdown-theme.css');
|
const themeCssPath = path.join(DIST_DIR, 'milkdown-theme.css');
|
||||||
|
const spellcheckPath = path.join(DIST_DIR, 'spellcheck.js');
|
||||||
|
|
||||||
// Prefer prebuilt assets in packaged VSIX to avoid requiring esbuild at runtime.
|
// Prefer prebuilt assets in packaged VSIX to avoid requiring esbuild at runtime.
|
||||||
if (fs.existsSync(milkdownPath) && fs.existsSync(commonCssPath) && fs.existsSync(themeCssPath)) return;
|
if (fs.existsSync(milkdownPath) && fs.existsSync(commonCssPath) && fs.existsSync(themeCssPath) && fs.existsSync(spellcheckPath)) return;
|
||||||
|
|
||||||
await vscode.window.withProgress(
|
await vscode.window.withProgress(
|
||||||
{ location: vscode.ProgressLocation.Notification, title: 'Blog Editor: Bundling Milkdown…' },
|
{ location: vscode.ProgressLocation.Notification, title: 'Blog Editor: Bundling Milkdown…' },
|
||||||
@@ -253,7 +681,7 @@ async function ensureBundle() {
|
|||||||
const crepeDir = path.join(crepePkg, 'lib', 'theme');
|
const crepeDir = path.join(crepePkg, 'lib', 'theme');
|
||||||
const fontLoaders = { '.woff2': 'dataurl', '.woff': 'dataurl', '.ttf': 'dataurl', '.eot': 'dataurl', '.svg': 'dataurl' };
|
const fontLoaders = { '.woff2': 'dataurl', '.woff': 'dataurl', '.ttf': 'dataurl', '.eot': 'dataurl', '.svg': 'dataurl' };
|
||||||
|
|
||||||
const [jsResult, cssCommon, cssTheme] = await Promise.all([
|
const [jsResult, cssCommon, cssTheme, spellcheckResult] = await Promise.all([
|
||||||
esbuild.build({
|
esbuild.build({
|
||||||
stdin: { contents: `export { Crepe, CrepeFeature } from '@milkdown/crepe';`, resolveDir: rootDir, loader: 'js' },
|
stdin: { contents: `export { Crepe, CrepeFeature } from '@milkdown/crepe';`, resolveDir: rootDir, loader: 'js' },
|
||||||
bundle: true, format: 'esm', platform: 'browser', target: 'es2022', minify: true, write: false,
|
bundle: true, format: 'esm', platform: 'browser', target: 'es2022', minify: true, write: false,
|
||||||
@@ -266,11 +694,22 @@ async function ensureBundle() {
|
|||||||
entryPoints: [path.join(crepeDir, 'frame-dark', 'style.css')],
|
entryPoints: [path.join(crepeDir, 'frame-dark', 'style.css')],
|
||||||
bundle: true, write: false, loader: fontLoaders,
|
bundle: true, write: false, loader: fontLoaders,
|
||||||
}),
|
}),
|
||||||
|
esbuild.build({
|
||||||
|
entryPoints: [path.join(EXT_DIR, 'spellcheck.js')],
|
||||||
|
bundle: true,
|
||||||
|
format: 'esm',
|
||||||
|
platform: 'browser',
|
||||||
|
target: 'es2022',
|
||||||
|
minify: true,
|
||||||
|
write: false,
|
||||||
|
loader: { '.aff': 'text', '.dic': 'text' },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
fs.writeFileSync(path.join(DIST_DIR, 'milkdown.js'), jsResult.outputFiles[0].contents);
|
fs.writeFileSync(path.join(DIST_DIR, 'milkdown.js'), jsResult.outputFiles[0].contents);
|
||||||
fs.writeFileSync(path.join(DIST_DIR, 'milkdown-common.css'), cssCommon.outputFiles[0].contents);
|
fs.writeFileSync(path.join(DIST_DIR, 'milkdown-common.css'), cssCommon.outputFiles[0].contents);
|
||||||
fs.writeFileSync(path.join(DIST_DIR, 'milkdown-theme.css'), cssTheme.outputFiles[0].contents);
|
fs.writeFileSync(path.join(DIST_DIR, 'milkdown-theme.css'), cssTheme.outputFiles[0].contents);
|
||||||
|
fs.writeFileSync(path.join(DIST_DIR, 'spellcheck.js'), spellcheckResult.outputFiles[0].contents);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -330,6 +769,7 @@ async function openEditor(context, initialSlug) {
|
|||||||
function buildHtml(webview) {
|
function buildHtml(webview) {
|
||||||
const nonce = crypto.randomBytes(16).toString('hex');
|
const nonce = crypto.randomBytes(16).toString('hex');
|
||||||
const milkdownJs = webview.asWebviewUri(vscode.Uri.file(path.join(DIST_DIR, 'milkdown.js')));
|
const milkdownJs = webview.asWebviewUri(vscode.Uri.file(path.join(DIST_DIR, 'milkdown.js')));
|
||||||
|
const spellcheckJs = webview.asWebviewUri(vscode.Uri.file(path.join(DIST_DIR, 'spellcheck.js')));
|
||||||
const commonCss = webview.asWebviewUri(vscode.Uri.file(path.join(DIST_DIR, 'milkdown-common.css')));
|
const commonCss = webview.asWebviewUri(vscode.Uri.file(path.join(DIST_DIR, 'milkdown-common.css')));
|
||||||
const themeCss = webview.asWebviewUri(vscode.Uri.file(path.join(DIST_DIR, 'milkdown-theme.css')));
|
const themeCss = webview.asWebviewUri(vscode.Uri.file(path.join(DIST_DIR, 'milkdown-theme.css')));
|
||||||
const csp = webview.cspSource;
|
const csp = webview.cspSource;
|
||||||
@@ -339,6 +779,7 @@ function buildHtml(webview) {
|
|||||||
.replace(/\{\{NONCE\}\}/g, nonce)
|
.replace(/\{\{NONCE\}\}/g, nonce)
|
||||||
.replace(/\{\{CSP_SOURCE\}\}/g, csp)
|
.replace(/\{\{CSP_SOURCE\}\}/g, csp)
|
||||||
.replace('{{MILKDOWN_JS}}', milkdownJs.toString())
|
.replace('{{MILKDOWN_JS}}', milkdownJs.toString())
|
||||||
|
.replace('{{SPELLCHECK_JS}}', spellcheckJs.toString())
|
||||||
.replace('{{COMMON_CSS}}', commonCss.toString())
|
.replace('{{COMMON_CSS}}', commonCss.toString())
|
||||||
.replace('{{THEME_CSS}}', themeCss.toString());
|
.replace('{{THEME_CSS}}', themeCss.toString());
|
||||||
}
|
}
|
||||||
@@ -391,7 +832,7 @@ function revertWebviewImageUris(content, websiteBaseUri) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles messages from the webview.
|
* Handles messages from the webview.
|
||||||
* @param {{ type: string, slug?: string, content?: string, filename?: string, dataBase64?: string, blobUrl?: string }} msg
|
* @param {{ type: string, slug?: string, oldSlug?: string, newSlug?: string, content?: string, filename?: string, dataBase64?: string, blobUrl?: string, title?: string, body?: string, tags?: string }} msg
|
||||||
* @param {string} blogDir
|
* @param {string} blogDir
|
||||||
* @param {string} websiteDir
|
* @param {string} websiteDir
|
||||||
*/
|
*/
|
||||||
@@ -486,6 +927,28 @@ function handleMessage(msg, blogDir, websiteDir) {
|
|||||||
sidebarProvider?.refresh();
|
sidebarProvider?.refresh();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'generateTags': {
|
||||||
|
const title = (msg.title || '').trim();
|
||||||
|
const body = (msg.body || '').trim();
|
||||||
|
const tags = (msg.tags || '').trim();
|
||||||
|
log('AI tags: webview requested generation');
|
||||||
|
if (!title && !body) {
|
||||||
|
log('AI tags: rejected request - missing title/body');
|
||||||
|
webview.postMessage({ type: 'error', message: 'Need title or body for tag generation.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
webview.postMessage({ type: 'aiTagsStarted' });
|
||||||
|
generateTagsWithLmStudio({ title, body, tags })
|
||||||
|
.then(generated => {
|
||||||
|
log('AI tags: sending generated tags to webview');
|
||||||
|
webview.postMessage({ type: 'aiTagsGenerated', tags: generated });
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
log(`AI tags: generation failed ${String(err)}`);
|
||||||
|
webview.postMessage({ type: 'error', message: `AI tag generation failed: ${String(err)}` });
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'ready': {
|
case 'ready': {
|
||||||
if (pendingOpenSlug) {
|
if (pendingOpenSlug) {
|
||||||
webview.postMessage({ type: 'openSlug', slug: pendingOpenSlug });
|
webview.postMessage({ type: 'openSlug', slug: pendingOpenSlug });
|
||||||
|
|||||||
@@ -2,11 +2,18 @@
|
|||||||
"name": "blog-editor",
|
"name": "blog-editor",
|
||||||
"displayName": "Blog Editor",
|
"displayName": "Blog Editor",
|
||||||
"description": "WYSIWYG Milkdown blog post editor for this workspace",
|
"description": "WYSIWYG Milkdown blog post editor for this workspace",
|
||||||
"version": "0.1.12",
|
"version": "0.1.32",
|
||||||
"publisher": "local",
|
"publisher": "local",
|
||||||
"engines": { "vscode": "^1.80.0" },
|
"engines": {
|
||||||
"categories": ["Other"],
|
"vscode": "^1.80.0"
|
||||||
"repository": { "type": "git", "url": "https://github.com/local/blog-editor" },
|
},
|
||||||
|
"categories": [
|
||||||
|
"Other"
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/local/blog-editor"
|
||||||
|
},
|
||||||
"activationEvents": [],
|
"activationEvents": [],
|
||||||
"main": "./extension.js",
|
"main": "./extension.js",
|
||||||
"contributes": {
|
"contributes": {
|
||||||
@@ -79,6 +86,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"package": "node ../build-dist.js && npx @vscode/vsce package --out blog-editor.vsix --no-dependencies"
|
"prepackage": "node ./bump-version.js",
|
||||||
|
"package": "node ./build-dist.js",
|
||||||
|
"postpackage": "npx @vscode/vsce package --out blog-editor.vsix --no-dependencies --allow-missing-repository"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
273
tools/blogeditor/spellcheck.js
Normal file
273
tools/blogeditor/spellcheck.js
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
import nspell from 'nspell';
|
||||||
|
import affData from 'dictionary-en-us/index.aff';
|
||||||
|
import dicData from 'dictionary-en-us/index.dic';
|
||||||
|
|
||||||
|
const spell = nspell(affData, dicData);
|
||||||
|
const SPELLCHECK_HIGHLIGHT = 'blog-editor-spellcheck';
|
||||||
|
const WORD_PATTERN = /[A-Za-z][A-Za-z'’-]{1,}/g;
|
||||||
|
const SKIP_TAGS = new Set(['CODE', 'PRE', 'A', 'KBD', 'SAMP']);
|
||||||
|
const PERSONAL_DICTIONARY_KEY = 'blog-editor-personal-dictionary-v1';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} value
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function isWordChar(value) {
|
||||||
|
return /[A-Za-z'’-]/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight editor spellcheck controller using CSS Highlights.
|
||||||
|
*/
|
||||||
|
export class BlogEditorSpellcheck {
|
||||||
|
/**
|
||||||
|
* @param {HTMLElement} root
|
||||||
|
* @param {(message: string, isError?: boolean) => void} updateStatus
|
||||||
|
*/
|
||||||
|
constructor(root, updateStatus) {
|
||||||
|
this.root = root;
|
||||||
|
this.updateStatus = updateStatus;
|
||||||
|
this.enabled = true;
|
||||||
|
this.pendingTimer = 0;
|
||||||
|
this.highlightName = SPELLCHECK_HIGHLIGHT;
|
||||||
|
this.personalWords = new Set();
|
||||||
|
this.supported = typeof CSS !== 'undefined' && typeof CSS.highlights !== 'undefined' && typeof Highlight !== 'undefined';
|
||||||
|
this.loadPersonalDictionary();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {boolean} enabled
|
||||||
|
*/
|
||||||
|
setEnabled(enabled) {
|
||||||
|
this.enabled = enabled;
|
||||||
|
if (!this.supported) {
|
||||||
|
this.updateStatus(enabled ? 'spellcheck unavailable in this renderer' : 'spellcheck off', !enabled ? false : true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
this.clear();
|
||||||
|
this.updateStatus('spellcheck off');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
schedule() {
|
||||||
|
if (!this.supported || !this.enabled) return;
|
||||||
|
window.clearTimeout(this.pendingTimer);
|
||||||
|
this.pendingTimer = window.setTimeout(() => this.refresh(), 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh() {
|
||||||
|
if (!this.supported || !this.enabled) return;
|
||||||
|
const highlight = new Highlight();
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for (const node of this.iterTextNodes()) {
|
||||||
|
const text = node.textContent ?? '';
|
||||||
|
WORD_PATTERN.lastIndex = 0;
|
||||||
|
let match;
|
||||||
|
while ((match = WORD_PATTERN.exec(text)) !== null) {
|
||||||
|
const word = match[0];
|
||||||
|
if (!this.isMisspelledWord(word)) continue;
|
||||||
|
const range = new Range();
|
||||||
|
range.setStart(node, match.index);
|
||||||
|
range.setEnd(node, match.index + word.length);
|
||||||
|
highlight.add(range);
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CSS.highlights.set(this.highlightName, highlight);
|
||||||
|
this.updateStatus(count > 0 ? `spellcheck on · ${count} issue${count === 1 ? '' : 's'}` : 'spellcheck on · clear');
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
if (!this.supported) return;
|
||||||
|
window.clearTimeout(this.pendingTimer);
|
||||||
|
CSS.highlights.delete(this.highlightName);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the personal dictionary from browser storage.
|
||||||
|
*/
|
||||||
|
loadPersonalDictionary() {
|
||||||
|
if (typeof localStorage === 'undefined') return;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(PERSONAL_DICTIONARY_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
const words = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(words)) return;
|
||||||
|
for (const entry of words) {
|
||||||
|
if (typeof entry !== 'string' || !entry.trim()) continue;
|
||||||
|
const word = entry.trim();
|
||||||
|
this.personalWords.add(word);
|
||||||
|
spell.add(word);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.personalWords.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists the personal dictionary to browser storage.
|
||||||
|
*/
|
||||||
|
savePersonalDictionary() {
|
||||||
|
if (typeof localStorage === 'undefined') return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PERSONAL_DICTIONARY_KEY, JSON.stringify([...this.personalWords].sort((a, b) => a.localeCompare(b))));
|
||||||
|
} catch {
|
||||||
|
// Ignore storage quota / privacy errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a word to the personal dictionary and refreshes highlights.
|
||||||
|
* @param {string} word
|
||||||
|
*/
|
||||||
|
addToDictionary(word) {
|
||||||
|
const normalized = typeof word === 'string' ? word.trim() : '';
|
||||||
|
if (!normalized) return;
|
||||||
|
if (this.personalWords.has(normalized)) return;
|
||||||
|
this.personalWords.add(normalized);
|
||||||
|
spell.add(normalized);
|
||||||
|
this.savePersonalDictionary();
|
||||||
|
this.refresh();
|
||||||
|
this.updateStatus(`added "${normalized}" to dictionary`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} word
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
isMisspelledWord(word) {
|
||||||
|
if (!this.shouldCheckWord(word)) return false;
|
||||||
|
return !spell.correct(word);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} word
|
||||||
|
* @param {number} [limit]
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
getSuggestions(word, limit = 6) {
|
||||||
|
if (!word || !this.isMisspelledWord(word)) return [];
|
||||||
|
return spell.suggest(word).slice(0, Math.max(0, limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
*iterTextNodes() {
|
||||||
|
const walker = document.createTreeWalker(this.root, NodeFilter.SHOW_TEXT, {
|
||||||
|
acceptNode: node => {
|
||||||
|
if (!(node.parentElement instanceof HTMLElement)) return NodeFilter.FILTER_REJECT;
|
||||||
|
if (!node.textContent || !WORD_PATTERN.test(node.textContent)) return NodeFilter.FILTER_REJECT;
|
||||||
|
if (node.parentElement.closest('[data-type="image-block"]')) return NodeFilter.FILTER_REJECT;
|
||||||
|
if (this.shouldSkip(node.parentElement)) return NodeFilter.FILTER_REJECT;
|
||||||
|
return NodeFilter.FILTER_ACCEPT;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let current;
|
||||||
|
while ((current = walker.nextNode())) {
|
||||||
|
yield current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {HTMLElement} element
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
shouldSkip(element) {
|
||||||
|
let current = element;
|
||||||
|
while (current && current !== this.root) {
|
||||||
|
if (SKIP_TAGS.has(current.tagName)) return true;
|
||||||
|
if (current.getAttribute('contenteditable') === 'false') return true;
|
||||||
|
current = current.parentElement;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the misspelled word under a viewport point, if any.
|
||||||
|
* @param {number} clientX
|
||||||
|
* @param {number} clientY
|
||||||
|
* @returns {string | null}
|
||||||
|
*/
|
||||||
|
getMisspelledWordAtPoint(clientX, clientY) {
|
||||||
|
const hit = this.getMisspelledAtPoint(clientX, clientY);
|
||||||
|
return hit ? hit.word : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns misspelled word details under a viewport point, if any.
|
||||||
|
* @param {number} clientX
|
||||||
|
* @param {number} clientY
|
||||||
|
* @returns {{ word: string, range: Range } | null}
|
||||||
|
*/
|
||||||
|
getMisspelledAtPoint(clientX, clientY) {
|
||||||
|
if (!this.supported) return null;
|
||||||
|
|
||||||
|
let node = null;
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
if (typeof document.caretPositionFromPoint === 'function') {
|
||||||
|
const pos = document.caretPositionFromPoint(clientX, clientY);
|
||||||
|
if (pos) {
|
||||||
|
node = pos.offsetNode;
|
||||||
|
offset = pos.offset;
|
||||||
|
}
|
||||||
|
} else if (typeof document.caretRangeFromPoint === 'function') {
|
||||||
|
const range = document.caretRangeFromPoint(clientX, clientY);
|
||||||
|
if (range) {
|
||||||
|
node = range.startContainer;
|
||||||
|
offset = range.startOffset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(node instanceof Text)) return null;
|
||||||
|
if (!(node.parentElement instanceof HTMLElement)) return null;
|
||||||
|
if (this.shouldSkip(node.parentElement)) return null;
|
||||||
|
|
||||||
|
const text = node.textContent ?? '';
|
||||||
|
if (!text) return null;
|
||||||
|
|
||||||
|
let start = Math.min(Math.max(offset, 0), text.length);
|
||||||
|
let end = start;
|
||||||
|
|
||||||
|
if (start > 0 && !isWordChar(text[start]) && isWordChar(text[start - 1])) {
|
||||||
|
start -= 1;
|
||||||
|
end = start;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (start > 0 && isWordChar(text[start - 1])) start -= 1;
|
||||||
|
while (end < text.length && isWordChar(text[end])) end += 1;
|
||||||
|
|
||||||
|
if (end <= start) return null;
|
||||||
|
|
||||||
|
const word = text.slice(start, end);
|
||||||
|
if (!this.isMisspelledWord(word)) return null;
|
||||||
|
|
||||||
|
const range = new Range();
|
||||||
|
range.setStart(node, start);
|
||||||
|
range.setEnd(node, end);
|
||||||
|
return { word, range };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} word
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
shouldCheckWord(word) {
|
||||||
|
if (word.length < 3) return false;
|
||||||
|
if (/^[A-Z0-9_-]+$/.test(word)) return false;
|
||||||
|
if (/\d/.test(word)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
const GITHUB_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>';
|
||||||
|
const BLUESKY_SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037-.856 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.196-7.28-1.304-7.847-2.97-.104-.305-.152-.448-.153-.327 0-.121-.05.022-.153.327-.568 1.666-2.782 8.166-7.847 2.97-2.667-2.737-1.432-5.473 3.422-6.3-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026"/></svg>';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {Object} NavLink
|
* @typedef {Object} NavLink
|
||||||
* @property {string} label
|
* @property {string} label
|
||||||
* @property {string} href
|
* @property {string} href
|
||||||
* @property {boolean} [external]
|
* @property {boolean} [external]
|
||||||
|
* @property {string} [svg] - Inline SVG string shown instead of label text.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** @type {NavLink[]} */
|
/** @type {NavLink[]} */
|
||||||
@@ -12,9 +16,9 @@ const NAV_LINKS = [
|
|||||||
{ label: 'home', href: '/' },
|
{ label: 'home', href: '/' },
|
||||||
{ label: 'blog', href: '/blog.html' },
|
{ label: 'blog', href: '/blog.html' },
|
||||||
{ label: 'docs', href: '/docs/' },
|
{ label: 'docs', href: '/docs/' },
|
||||||
{ label: 'github', href: 'https://github.com/litruv', external: true },
|
|
||||||
{ label: 'bluesky', href: 'https://bsky.app/profile/lit.mates.dev', external: true },
|
|
||||||
{ label: 'materials', href: '/materials' },
|
{ label: 'materials', href: '/materials' },
|
||||||
|
{ label: 'GitHub', href: 'https://github.com/litruv', external: true, svg: GITHUB_SVG },
|
||||||
|
{ label: 'Bluesky', href: 'https://bsky.app/profile/lit.mates.dev', external: true, svg: BLUESKY_SVG },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,9 +28,11 @@ const NAV_LINKS = [
|
|||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
function renderNavLinkItems(indent = ' ') {
|
function renderNavLinkItems(indent = ' ') {
|
||||||
return NAV_LINKS.map(({ label, href, external }) => {
|
return NAV_LINKS.map(({ label, href, external, svg }) => {
|
||||||
const attrs = external ? ' target="_blank" rel="noopener"' : '';
|
const attrs = external ? ' target="_blank" rel="noopener"' : '';
|
||||||
return `${indent}<a href="${href}"${attrs} class="quick-link">${label}</a>`;
|
const content = svg ? svg : label;
|
||||||
|
const extraClass = svg ? ' quick-link--icon' : '';
|
||||||
|
return `${indent}<a href="${href}"${attrs} class="quick-link${extraClass}" aria-label="${label}"${svg ? ` title="${label}"` : ''}>${content}</a>`;
|
||||||
}).join('\n');
|
}).join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
BIN
website/data/blog/media/paste-1778545573604-pe0k3.png
Normal file
BIN
website/data/blog/media/paste-1778545573604-pe0k3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.1 MiB |
@@ -6,7 +6,7 @@ tags: announcement
|
|||||||
---
|
---
|
||||||
Congratulations on making it here, you've stumbled onto a game dev's blog, who's spent the past 12 years buried in Unreal Engine.
|
Congratulations on making it here, you've stumbled onto a game dev's blog, who's spent the past 12 years buried in Unreal Engine.
|
||||||
|
|
||||||
From studying Programming and 3D Art & Animation at the Academy of Interactive Entertainment, to developing architectural experiences at [Orbit Solutions](https://www.orbitsolutions.com.au/), running 1-on-1 mentoring sessions on [Fiverr](https://www.fiverr.com/litruv), working with [Superfarm](https://superverse.co/), and now building my own games and tools with [Mates.dev](https://mates.dev) - this blog is where I will be dumping my processes.
|
From studying Programming and 3D Art & Animation at the Academy of Interactive Entertainment, to developing architectural experiences at [Orbit Solutions](https://www.orbitsolutions.com.au/), running 1-on-1 mentoring sessions on [Fiverr](https://www.fiverr.com/litruv), working with [Superfarm](https://superverse.co/) and now building my own games and tools with [Mates.dev](https://mates.dev) - this blog is where I will be dumping my processes.
|
||||||
|
|
||||||
Expect game dev, systems design, experiments, tools, prototypes, and the occasional questionable decision that somehow worked out in the end.
|
Expect game dev, systems design, experiments, tools, prototypes, and the occasional questionable decision that somehow worked out in the end.
|
||||||
|
|
||||||
@@ -22,6 +22,8 @@ A lot of my work seems to revolve around systems-heavy gameplay, tools, modding
|
|||||||
|
|
||||||
These days I'm focused on building my own games and tech, while trying to document more of the process instead of letting years of experiments vanish into forgotten folders and messages.
|
These days I'm focused on building my own games and tech, while trying to document more of the process instead of letting years of experiments vanish into forgotten folders and messages.
|
||||||
|
|
||||||
|
<br />
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
This blog is mostly going to be a mix of development logs, technical breakdowns, experiments, ideas, failures, discoveries and whatever else crawls out of the workshop. Some posts will just document strange decisions made at unreasonable hours, the others might even be useful. Both are worth keeping around.
|
This blog is mostly going to be a mix of development logs, technical breakdowns, experiments, ideas, failures, discoveries and whatever else crawls out of the workshop. Some posts will just document strange decisions made at unreasonable hours, the others might even be useful. Both are worth keeping around.
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"slug": "markdown-test",
|
|
||||||
"title": "Markdown Formatting Test",
|
|
||||||
"date": "2026-09-11",
|
|
||||||
"author": "Test Author",
|
|
||||||
"tags": [
|
|
||||||
"test",
|
|
||||||
"formatting",
|
|
||||||
"markdown"
|
|
||||||
],
|
|
||||||
"content": "# Heading 1 test 123\n\n## Heading 2\n\n### Heading 3\n\n#### Heading 4\n\n##### Heading 5\n\n###### Heading 6\n\n## Text Formatting\n\nThis is **bold text** and this is *italic text* and this is ***bold italic text***.\n\nHere's some `inline code` in a sentence.\n\n## Links\n\nHere's a [regular link](https://example.com) and here's a [YouTube link](https://youtube.com/watch?v=test).\n\n## Lists\n\nUnordered list:\n\n* First item\n\n* Second item\n\n* Third item\n\nOrdered list:\n\n1. First step\n2. Second step\n3. Third step\n\n## Code Blocks\n\nStandard JavaScript:\n\n```javascript\nconst hello = \"world\";\nconsole.log(hello);\n\nfunction test() {\n return true;\n}\n```\n\nPython with syntax highlighting:\n\n```python\ndef hello_world():\n print(\"Hello, World!\")\n return True\n```\n\nCode with max height (200px):\n\n```javascript\n// This is a long code block that will scroll\nconst data = [1, 2, 3, 4, 5];\n\nfunction processData(arr) {\n return arr.map(x => x * 2);\n}\n\nconsole.log(processData(data));\n\n// Adding more lines to demonstrate scrolling\nfor (let i = 0; i < 10; i++) {\n console.log(`Iteration ${i}`);\n}\n\n// Even more content\nconst obj = {\n name: \"Test\",\n value: 42,\n nested: {\n deep: true\n }\n};\n```\n\n## Blockquotes\n\n> This is a blockquote.\n> It can span multiple lines.\n> And continues here.\n\n## Horizontal Rule\n\n***\n\n## Images\n\n\n\n\n\n## Mixed Content\n\n<br />\n\nYou can mix **bold**, *italic*, and `code` in the same paragraph. Here's a [link with](https://example.com) **[bold](https://example.com)** [text](https://example.com) too.\n\n### Nested Lists\n\n* Top level itemsss\n\n* Another top level\n\n * Nested item (if supported)\n\n * Another nested\n\n1. Numbered item\n2. Another numbered\n\n * Mixed with bullets\n\n * More bullets\n\n## Special Characters\n\nTesting special chars: < > & \" '\n\n## Long Paragraph\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\n\nDuis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.\n"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"slug": "testddd",
|
|
||||||
"title": "test d d d d",
|
|
||||||
"date": "2026-05-10",
|
|
||||||
"author": "it me",
|
|
||||||
"tags": [],
|
|
||||||
"content": "asdffdsaasdffdsa\n\n| <br /> | <br /> | <br /> | <br /> |\n| :----- | :----- | :----- | -----: |\n| <br /> | <br /> | <br /> | <br /> |\n| <br /> | <br /> | <br /> | <br /> |\n\n<br />\n\nthis is a test post, should't publish i guess, but who's reading lmao\n"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"slug": "welcome",
|
|
||||||
"title": "Welcome to My Blog",
|
|
||||||
"date": "2026-05-10",
|
|
||||||
"author": "Max Litruv Boonzaayer",
|
|
||||||
"tags": [
|
|
||||||
"welcome",
|
|
||||||
"meta",
|
|
||||||
"introduction"
|
|
||||||
],
|
|
||||||
"content": "# Welcome to My Blog\n\nThis is my first blog post! I'm excited to share my thoughts and experiences with you.\n\n## What This Blog Is About\n\nThis blog is built using a **custom node-based graph system** where each blog post is represented as a node in an interactive visual graph. Pretty cool, right?\n\n### Features\n\n* 📝 Markdown support with YAML front matter\n\n* 📅 Date-based organization\n\n* 🏷️ Tag system for categorization\n\n* 🎨 Interactive node-based visualization\n\n* 📁 Media support in `data/blog/media/`\n\n## Technical Details\n\nThe build system automatically:\n\n1. Scans the `data/blog/` directory for `.md` files\n2. Parses YAML front matter for metadata\n3. Generates a `blogs.json` file\n4. Makes posts available to the BlogPostNode\n\nYou can reference images like this:\n\n\n## Code Examples\n\nHere's some example code:\n\n```javascript\nconst greeting = \"Hello, World!\";\r\nconsole.log(greeting);\n```\n\n## Conclusion\n\nStay tuned for more posts! This is just the beginning of an exciting journey.\n"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"slug": "node-graphs",
|
|
||||||
"title": "Building Interactive Node Graphs",
|
|
||||||
"date": "2026-04-15",
|
|
||||||
"author": "Max Litruv Boonzaayer",
|
|
||||||
"tags": [
|
|
||||||
"javascript",
|
|
||||||
"graph-systems",
|
|
||||||
"tutorial"
|
|
||||||
],
|
|
||||||
"content": "\r\n# Building Interactive Node Graphs\r\n\r\nIn this post, I'll share some insights on building interactive node-based graph systems for the web.\r\n\r\n## Why Node Graphs?\r\n\r\nNode graphs are powerful visual programming tools that make complex logic easier to understand and manipulate:\r\n\r\n- **Visual Clarity**: See the flow of data and execution at a glance\r\n- **Modularity**: Each node is a self-contained unit\r\n- **Flexibility**: Easy to add new node types and behaviors\r\n\r\n## The Architecture\r\n\r\nThe system is built on several key classes:\r\n\r\n### NodeBase\r\nThe abstract base class that all nodes inherit from. It defines the blueprint interface:\r\n\r\n```javascript\r\nclass NodeBase {\r\n static NodeType = \"\";\r\n static BlueprintPure_GetDefaultPins() { }\r\n BlueprintNativeEvent_OnRender(article, graphNode, renderCtx) { }\r\n async BlueprintCallable_Execute(ctx) { }\r\n}\r\n```\r\n\r\n### Node Registry\r\nAutomatically registers node types and creates instances based on type strings.\r\n\r\n### Execution Context\r\nHandles the graph traversal and execution flow when nodes are triggered.\r\n\r\n## Creating Custom Nodes\r\n\r\nTo create a new node type:\r\n\r\n1. Extend `NodeBase`\r\n2. Set a unique `NodeType`\r\n3. Define pins with `BlueprintPure_GetDefaultPins()`\r\n4. Implement rendering in `BlueprintNativeEvent_OnRender()`\r\n5. Implement logic in `BlueprintCallable_Execute()`\r\n\r\nThat's the blueprint system in a nutshell!\r\n"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
5
website/icons/bluesky-black-icon.svg
Normal file
5
website/icons/bluesky-black-icon.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||||
|
<svg width="100%" height="100%" viewBox="0 0 512 453" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||||
|
<path d="M110.985,30.441C169.68,74.658 232.822,164.297 255.998,212.402C279.175,164.297 342.321,74.658 401.015,30.441C443.376,-1.455 512,-26.142 512,52.402C512,68.082 503.038,184.177 497.777,203.03C479.504,268.545 412.904,285.258 353.665,275.146C457.215,292.825 483.554,351.383 426.665,409.945C318.624,521.168 271.377,382.04 259.279,346.391C255.791,336.129 256.288,335.893 252.719,346.391C240.621,382.04 193.377,521.168 85.336,409.945C28.447,351.383 54.785,292.825 158.335,275.146C99.096,285.258 32.495,268.545 14.223,203.03C8.962,184.177 0,68.082 0,52.402C0,-26.142 68.633,-1.455 110.985,30.441ZM96.799,49.275C84.105,39.715 68.726,30.778 54.004,26.256C44.996,23.49 36.38,21.013 30.395,25.642C24.778,29.987 23.579,38.884 23.579,52.402C23.579,67.42 31.895,178.636 36.934,196.692L36.935,196.696C51.648,249.45 106.666,260.046 154.367,251.903C167.204,249.712 179.386,258.342 181.578,271.178C183.769,284.015 175.14,296.197 162.303,298.389C135.291,303 114.19,310.441 99.405,320.491C88.594,327.839 81.552,336.27 79.887,346.191C77.4,361.01 86.042,376.833 102.249,393.516C127.513,419.524 147.307,430.205 164.581,428.535C178.755,427.164 189.413,417.046 198.267,405.504C215.828,382.612 225.787,352.38 230.391,338.814L230.395,338.801C232.485,332.655 234.064,328.904 235.05,327.229L238.226,322.916L243.134,318.787L249.091,316.051L256.946,315.045L263.15,316.115L269.971,319.512L274.366,323.551L277.231,327.669C278.172,329.34 279.629,332.994 281.604,338.803L281.607,338.814C286.211,352.38 296.171,382.612 313.732,405.504C322.586,417.046 333.245,427.164 347.419,428.535C364.693,430.205 384.488,419.524 409.752,393.516C425.959,376.833 434.601,361.01 432.114,346.191C430.449,336.27 423.406,327.84 412.596,320.491C397.81,310.441 376.709,303 349.697,298.389C336.86,296.197 328.231,284.015 330.422,271.178C332.614,258.342 344.796,249.712 357.633,251.903C405.334,260.046 460.351,249.45 475.065,196.695L475.066,196.692C480.105,178.636 488.421,67.42 488.421,52.402C488.421,38.884 487.222,29.987 481.606,25.642C475.622,21.012 467.007,23.49 458,26.256C443.277,30.778 427.897,39.716 415.2,49.276C359.312,91.38 299.309,176.831 277.24,222.636C273.309,230.795 265.054,235.981 255.998,235.981C246.942,235.981 238.686,230.794 234.756,222.636C212.688,176.831 152.688,91.379 96.799,49.275Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
4
website/icons/github-outline.svg
Normal file
4
website/icons/github-outline.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||||
|
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 2C6.475 2 2 6.475 2 12C2 16.425 4.8625 20.1625 8.8375 21.4875C9.3375 21.575 9.525 21.275 9.525 21.0125C9.525 20.775 9.5125 19.9875 9.5125 19.15C7 19.6125 6.35 18.5375 6.15 17.975C6.0375 17.6875 5.55 16.8 5.125 16.5625C4.775 16.375 4.275 15.9125 5.1125 15.9C5.9 15.8875 6.4625 16.625 6.65 16.925C7.55 18.4375 8.9875 18.0125 9.5625 17.75C9.65 17.1 9.9125 16.6625 10.2 16.4125C7.975 16.1625 5.65 15.3 5.65 11.475C5.65 10.3875 6.0375 9.4875 6.675 8.7875C6.575 8.5375 6.225 7.5125 6.775 6.1375C6.775 6.1375 7.6125 5.875 9.525 7.1625C10.325 6.9375 11.175 6.825 12.025 6.825C12.875 6.825 13.725 6.9375 14.525 7.1625C16.4375 5.8625 17.275 6.1375 17.275 6.1375C17.825 7.5125 17.475 8.5375 17.375 8.7875C18.0125 9.4875 18.4 10.375 18.4 11.475C18.4 15.3125 16.0625 16.1625 13.8375 16.4125C14.2 16.725 14.5125 17.325 14.5125 18.2625C14.5125 19.6 14.5 20.675 14.5 21.0125C14.5 21.275 14.6875 21.5875 15.1875 21.4875C17.1727 20.8173 18.8977 19.5415 20.1198 17.8395C21.3419 16.1376 21.9995 14.0953 22 12C22 6.475 17.525 2 12 2Z" stroke="#000000" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1428
website/scripts/BlogComments.js
Normal file
1428
website/scripts/BlogComments.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
|||||||
import { MarkdownRenderer } from "./MarkdownRenderer.js";
|
import { MarkdownRenderer } from "./MarkdownRenderer.js";
|
||||||
|
import { BlogComments } from "./BlogComments.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decodes markdown payload from a JSON script element.
|
* Decodes markdown payload from a JSON script element.
|
||||||
@@ -65,6 +66,33 @@ function renderStaticBlogPost() {
|
|||||||
wireCopyButtons(target);
|
wireCopyButtons(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mounts Matrix-backed comments/reactions for the current blog post.
|
||||||
|
*
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function setupBlogComments() {
|
||||||
|
/** @type {HTMLElement | null} */
|
||||||
|
const mount = document.querySelector("[data-blog-comments]");
|
||||||
|
if (!mount) return;
|
||||||
|
|
||||||
|
const slug = window.location.pathname
|
||||||
|
.split("/")
|
||||||
|
.filter(Boolean)
|
||||||
|
.pop() || "unknown";
|
||||||
|
|
||||||
|
const homeserver = mount.dataset.matrixHomeserver || "https://chat.ruv.wtf";
|
||||||
|
const roomAlias = mount.dataset.matrixRoom || "#general:chat.ruv.wtf";
|
||||||
|
|
||||||
|
const comments = new BlogComments({
|
||||||
|
homeserver,
|
||||||
|
roomAlias,
|
||||||
|
postSlug: slug
|
||||||
|
});
|
||||||
|
|
||||||
|
await comments.initialize(mount);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggles the compact top bar when the hero logo scrolls out of view.
|
* Toggles the compact top bar when the hero logo scrolls out of view.
|
||||||
*
|
*
|
||||||
@@ -73,26 +101,26 @@ function renderStaticBlogPost() {
|
|||||||
function setupScrollTopBar() {
|
function setupScrollTopBar() {
|
||||||
const body = document.body;
|
const body = document.body;
|
||||||
const bar = document.querySelector("[data-blog-scroll-topbar]");
|
const bar = document.querySelector("[data-blog-scroll-topbar]");
|
||||||
const heroLogo = document.querySelector(".blog-logo-link");
|
|
||||||
|
|
||||||
if (!bar) return;
|
if (!bar) return;
|
||||||
|
|
||||||
if (heroLogo && "IntersectionObserver" in window) {
|
let lastScrollTop = 0;
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
([entry]) => {
|
const applyState = () => {
|
||||||
body.classList.toggle("blog-scrolled", !entry.isIntersecting);
|
const scrollTop = body.scrollTop;
|
||||||
},
|
const scrollingDown = scrollTop > lastScrollTop;
|
||||||
{ threshold: 0 }
|
lastScrollTop = scrollTop;
|
||||||
);
|
|
||||||
observer.observe(heroLogo);
|
if (scrollTop < 80) {
|
||||||
} else {
|
body.classList.remove("blog-scrolled");
|
||||||
// Fallback: fixed pixel threshold
|
} else if (scrollingDown) {
|
||||||
const applyState = () => {
|
body.classList.remove("blog-scrolled");
|
||||||
body.classList.toggle("blog-scrolled", window.scrollY >= 80);
|
} else {
|
||||||
};
|
body.classList.add("blog-scrolled");
|
||||||
window.addEventListener("scroll", applyState, { passive: true });
|
}
|
||||||
applyState();
|
};
|
||||||
}
|
body.addEventListener("scroll", applyState, { passive: true });
|
||||||
|
applyState();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -200,10 +228,11 @@ function setupPeekSplines() {
|
|||||||
*
|
*
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
function initializeBlogPage() {
|
async function initializeBlogPage() {
|
||||||
renderStaticBlogPost();
|
renderStaticBlogPost();
|
||||||
setupScrollTopBar();
|
setupScrollTopBar();
|
||||||
setupPeekSplines();
|
setupPeekSplines();
|
||||||
|
await setupBlogComments();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
if (document.readyState === "loading") {
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ img {
|
|||||||
/* Workspace */
|
/* Workspace */
|
||||||
--bg: var(--ctp-crust);
|
--bg: var(--ctp-crust);
|
||||||
--grid-size: 20px;
|
--grid-size: 20px;
|
||||||
--grid-color: rgba(205, 214, 244, 0.04);
|
--grid-color: rgba(205, 214, 244, 0.024);
|
||||||
--grid-color-strong: rgba(205, 214, 244, 0.1);
|
--grid-color-strong: rgba(205, 214, 244, 0.06);
|
||||||
--workspace-zoom: 1;
|
--workspace-zoom: 1;
|
||||||
|
|
||||||
/* HUD */
|
/* HUD */
|
||||||
@@ -477,7 +477,7 @@ svg.world-image {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 3px 8px;
|
padding: 0 4px;
|
||||||
background: rgba(137, 180, 250, 0.15);
|
background: rgba(137, 180, 250, 0.15);
|
||||||
color: rgba(137, 180, 250, 0.9);
|
color: rgba(137, 180, 250, 0.9);
|
||||||
border: 1px solid rgba(137, 180, 250, 0.3);
|
border: 1px solid rgba(137, 180, 250, 0.3);
|
||||||
@@ -586,6 +586,18 @@ svg.world-image {
|
|||||||
color: rgba(205, 214, 244, 0.9);
|
color: rgba(205, 214, 244, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quick-link--icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
color: rgba(205, 214, 244, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-link--icon:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
.rss-link {
|
.rss-link {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -637,17 +649,22 @@ body.blog-page {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.blog-page .quick-links {
|
.blog-page .quick-links {
|
||||||
position: fixed;
|
position: static;
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 52px;
|
min-height: 52px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-page .quick-links-inner {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 10px 16px;
|
padding: 10px 0;
|
||||||
z-index: 620;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.blog-scroll-topbar {
|
.blog-scroll-topbar {
|
||||||
@@ -658,8 +675,7 @@ body.blog-page {
|
|||||||
height: 44px;
|
height: 44px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
padding: 0 16px;
|
||||||
padding: 6px 14px;
|
|
||||||
background: var(--ctp-base);
|
background: var(--ctp-base);
|
||||||
border-bottom: 1px solid rgba(205, 214, 244, 0.15);
|
border-bottom: 1px solid rgba(205, 214, 244, 0.15);
|
||||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.28);
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.28);
|
||||||
@@ -670,6 +686,15 @@ body.blog-page {
|
|||||||
z-index: 700;
|
z-index: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.blog-scroll-topbar-inner {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.blog-scroll-logo-link {
|
.blog-scroll-logo-link {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -753,7 +778,7 @@ body.blog-page {
|
|||||||
.blog-post-peek-wrapper {
|
.blog-post-peek-wrapper {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
width: 160px;
|
min-width: 60px;`n max-width: 250px;
|
||||||
height: 0;
|
height: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
@@ -768,7 +793,7 @@ body.blog-page {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.blog-post-peek {
|
.blog-post-peek {
|
||||||
width: 160px;
|
min-width: 60px;`n max-width: 250px;
|
||||||
background: var(--node-bg);
|
background: var(--node-bg);
|
||||||
border: 1px solid var(--node-border);
|
border: 1px solid var(--node-border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -866,15 +891,63 @@ body.blog-page {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1100px) {
|
/* ─── Blog Footer ─────────────────────────────────────────────────────────── */
|
||||||
.blog-post-peek {
|
|
||||||
display: none;
|
.blog-footer {
|
||||||
}
|
width: 100%;
|
||||||
.blog-post-splines {
|
border-top: 1px solid rgba(205, 214, 244, 0.1);
|
||||||
display: none;
|
margin-top: 48px;
|
||||||
}
|
padding: 32px 16px;
|
||||||
|
background: var(--ctp-mantle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.blog-footer-inner {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-footer-logo {
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-footer-logo:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-footer-logo-img {
|
||||||
|
height: 28px;
|
||||||
|
width: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-footer-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-footer-copy {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(205, 214, 244, 0.3);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.blog-post-peek {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.blog-post-splines {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.blog-post-title {
|
.blog-post-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -908,7 +981,7 @@ body.blog-page {
|
|||||||
border: 1px solid rgba(137, 180, 250, 0.35);
|
border: 1px solid rgba(137, 180, 250, 0.35);
|
||||||
background: rgba(137, 180, 250, 0.12);
|
background: rgba(137, 180, 250, 0.12);
|
||||||
color: var(--ctp-sky);
|
color: var(--ctp-sky);
|
||||||
padding: 3px 8px;
|
padding: 0 4px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
@@ -1172,6 +1245,390 @@ body.blog-page {
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.blog-comments {
|
||||||
|
margin-top: 28px;
|
||||||
|
border-top: 1px solid rgba(205, 214, 244, 0.12);
|
||||||
|
padding-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-title {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-status {
|
||||||
|
margin-top: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-status.is-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments.is-loading .blog-comments-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments.is-loading .blog-comments-status::before {
|
||||||
|
content: "";
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid rgba(205, 214, 244, 0.28);
|
||||||
|
border-top-color: var(--ctp-blue);
|
||||||
|
animation: blog-comments-spin 0.65s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-status.is-ok {
|
||||||
|
color: var(--ctp-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-status.is-error,
|
||||||
|
.blog-comments-system.is-error {
|
||||||
|
color: var(--ctp-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-post-reactions-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-item {
|
||||||
|
background: rgba(24, 24, 37, 0.58);
|
||||||
|
border: 1px solid rgba(205, 214, 244, 0.12);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-author {
|
||||||
|
color: var(--ctp-text);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-author-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-handle {
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 400;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-owner-badge {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-time {
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-body {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ctp-subtext1);
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-reactions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-reaction-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(205, 214, 244, 0.2);
|
||||||
|
background: rgba(49, 50, 68, 0.8);
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-reaction-emoji,
|
||||||
|
.blog-reaction-count {
|
||||||
|
display: inline;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-reaction-chip.is-own {
|
||||||
|
border-color: rgba(166, 227, 161, 0.7);
|
||||||
|
background: rgba(166, 227, 161, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-action-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 2px 7px;
|
||||||
|
transition: color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-action-btn:hover {
|
||||||
|
background: rgba(205, 214, 244, 0.08);
|
||||||
|
color: var(--ctp-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-action-btn.is-danger:hover {
|
||||||
|
color: var(--ctp-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-action-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-edit-area {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-edit-input {
|
||||||
|
flex: 1;
|
||||||
|
border: 1px solid rgba(205, 214, 244, 0.2);
|
||||||
|
background: rgba(24, 24, 37, 0.85);
|
||||||
|
border-radius: 7px;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
padding: 5px 9px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comment-edit-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--ctp-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-reaction-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
border: 1px solid rgba(205, 214, 244, 0.18);
|
||||||
|
background: rgba(49, 50, 68, 0.7);
|
||||||
|
color: var(--ctp-text);
|
||||||
|
border-radius: 8px;
|
||||||
|
min-width: 34px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-reaction-btn.is-own {
|
||||||
|
border-color: rgba(166, 227, 161, 0.7);
|
||||||
|
background: rgba(166, 227, 161, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-reaction-btn-emoji {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-reaction-btn-count {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--ctp-subtext1);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-reaction-btn-count.is-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-composer {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid rgba(205, 214, 244, 0.2);
|
||||||
|
background: rgba(24, 24, 37, 0.85);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--ctp-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-send {
|
||||||
|
border: 1px solid rgba(205, 214, 244, 0.22);
|
||||||
|
background: rgba(137, 180, 250, 0.2);
|
||||||
|
color: var(--ctp-text);
|
||||||
|
border-radius: 10px;
|
||||||
|
min-width: 78px;
|
||||||
|
padding: 0 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-send:hover {
|
||||||
|
background: rgba(137, 180, 250, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-send:disabled,
|
||||||
|
.blog-comments-input:disabled {
|
||||||
|
opacity: 0.65;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-system {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ctp-subtext0);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-identity {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--ctp-subtext1);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-identity.is-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-identity-label {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-identity-name {
|
||||||
|
color: var(--ctp-text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-identity-edit-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-identity-edit-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-identity-input {
|
||||||
|
border: 1px solid rgba(205, 214, 244, 0.25);
|
||||||
|
background: rgba(24, 24, 37, 0.85);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
min-width: 60px;`n max-width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-identity-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--ctp-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-identity-name-input {
|
||||||
|
border: 1px solid rgba(205, 214, 244, 0.25);
|
||||||
|
background: rgba(24, 24, 37, 0.85);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
min-width: 60px;
|
||||||
|
max-width: 250px;
|
||||||
|
vertical-align: baseline;
|
||||||
|
transition: border-color 0.15s, background 0.15s, width 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-identity-name-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--ctp-blue);
|
||||||
|
background: rgba(69, 71, 90, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-identity-name-input:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-identity-save-btn {
|
||||||
|
background: rgba(137, 180, 250, 0.2);
|
||||||
|
border: 1px solid rgba(137, 180, 250, 0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--ctp-text);
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-identity-save-btn:hover {
|
||||||
|
background: rgba(137, 180, 250, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blog-comments-spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
.blog-index-list {
|
.blog-index-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -1208,6 +1665,14 @@ body.blog-page {
|
|||||||
padding: 18px 16px 22px;
|
padding: 18px 16px 22px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.blog-comments-composer {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blog-comments-send {
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── HUD ─────────────────────────────────────────────────────────────────────── */
|
/* ─── HUD ─────────────────────────────────────────────────────────────────────── */
|
||||||
@@ -2626,7 +3091,7 @@ body.blog-page {
|
|||||||
color: rgba(243, 139, 168, 0.9);
|
color: rgba(243, 139, 168, 0.9);
|
||||||
border: 1px solid rgba(243, 139, 168, 0.3);
|
border: 1px solid rgba(243, 139, 168, 0.3);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 3px 8px;
|
padding: 0 4px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
@@ -2683,7 +3148,7 @@ body.blog-page {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
padding: 3px 8px;
|
padding: 0 4px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user