#!/usr/bin/env node // Banner image generator for docs.techswan.online // Usage: // node scripts/generate-banner.js # single article // node scripts/generate-banner.js --all # all missing // node scripts/generate-banner.js --all --force # regenerate all const { createCanvas, registerFont } = require('canvas'); const matter = require('gray-matter'); const fs = require('fs'); const path = require('path'); // --- Config --- const W = 1200, H = 630; const MAX_TITLE_WIDTH = 1000; const FONT_SIZES = [52, 44, 38]; const MAX_LINES = 3; const FONT_PATH = '/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc'; if (fs.existsSync(FONT_PATH)) { registerFont(FONT_PATH, { family: 'Noto Sans CJK JP', weight: 'bold' }); } const FONT_REGULAR = '/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc'; if (fs.existsSync(FONT_REGULAR)) { registerFont(FONT_REGULAR, { family: 'Noto Sans CJK JP', weight: 'normal' }); } const CATEGORIES = { 'docs': { label: 'Blog', color: '#0ea5e9' }, 'docs-tech': { label: 'Tech', color: '#10b981' }, }; const ROOT = path.resolve(__dirname, '..'); // --- Text wrapping --- function tokenize(text) { // Split into tokens: consecutive ASCII word chars as one token, each CJK char as one token const tokens = []; const re = /([A-Za-z0-9._\-]+)|(\s+)|([\s\S])/g; let m; while ((m = re.exec(text)) !== null) { if (m[1]) tokens.push({ text: m[1], breakable: false }); else if (m[2]) tokens.push({ text: ' ', breakable: true }); else if (m[3]) tokens.push({ text: m[3], breakable: true }); } return tokens; } function wrapText(ctx, text, maxWidth, fontSize, fontFamily) { ctx.font = `bold ${fontSize}px "${fontFamily}", sans-serif`; const tokens = tokenize(text); const lines = []; let currentLine = ''; for (const token of tokens) { if (token.text === ' ' && currentLine === '') continue; // skip leading space const testLine = currentLine + token.text; const metrics = ctx.measureText(testLine); if (metrics.width > maxWidth && currentLine !== '') { lines.push(currentLine); currentLine = token.breakable && token.text === ' ' ? '' : token.text; } else { currentLine = testLine; } } if (currentLine) lines.push(currentLine); return lines; } // --- Drawing --- function drawBanner(title, category) { const canvas = createCanvas(W, H); const ctx = canvas.getContext('2d'); const cat = CATEGORIES[category] || CATEGORIES['docs']; // Background gradient const grad = ctx.createLinearGradient(0, 0, W, H); grad.addColorStop(0, '#1a1a2e'); grad.addColorStop(1, '#16213e'); ctx.fillStyle = grad; ctx.fillRect(0, 0, W, H); // Decorative circles ctx.fillStyle = 'rgba(233,69,96,0.15)'; ctx.beginPath(); ctx.arc(1100, 100, 150, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = 'rgba(14,165,233,0.1)'; ctx.beginPath(); ctx.arc(1050, 500, 200, 0, Math.PI * 2); ctx.fill(); // Accent line ctx.fillStyle = '#e94560'; ctx.fillRect(60, 80, 6, 120); // Title - try font sizes until it fits ctx.fillStyle = '#ffffff'; ctx.textBaseline = 'top'; let lines, fontSize; for (fontSize of FONT_SIZES) { lines = wrapText(ctx, title, MAX_TITLE_WIDTH, fontSize, 'Noto Sans CJK JP'); if (lines.length <= MAX_LINES) break; } // If still too many lines, truncate if (lines.length > MAX_LINES) { lines = lines.slice(0, MAX_LINES); lines[MAX_LINES - 1] = lines[MAX_LINES - 1].replace(/.$/, '…'); } const lineHeight = Math.round(fontSize * 1.35); const titleStartY = 90; ctx.font = `bold ${fontSize}px "Noto Sans CJK JP", sans-serif`; lines.forEach((line, i) => { ctx.fillText(line, 90, titleStartY + i * lineHeight); }); // Category badge const badgeY = Math.max(titleStartY + lines.length * lineHeight + 40, 350); ctx.fillStyle = cat.color; ctx.beginPath(); ctx.roundRect(60, badgeY, 100, 36, 8); ctx.fill(); ctx.fillStyle = '#ffffff'; ctx.font = 'bold 18px "Noto Sans CJK JP", sans-serif'; ctx.textBaseline = 'middle'; ctx.fillText(cat.label, 60 + 50 - ctx.measureText(cat.label).width / 2, badgeY + 18); // Footer ctx.textBaseline = 'top'; ctx.fillStyle = 'rgba(255,255,255,0.6)'; ctx.font = '20px "Noto Sans CJK JP", sans-serif'; ctx.fillText('雑記 by swallow', 60, 550); ctx.fillStyle = 'rgba(255,255,255,0.3)'; const siteText = 'docs.techswan.online'; ctx.fillText(siteText, W - 60 - ctx.measureText(siteText).width, 550); return canvas.toBuffer('image/png'); } // --- Article discovery --- function findArticle(slug) { for (const dir of ['docs-tech', 'docs']) { const mdPath = path.join(ROOT, dir, slug, 'index.md'); if (fs.existsSync(mdPath)) return { mdPath, category: dir }; } return null; } function getAllArticles() { const articles = []; for (const dir of ['docs-tech', 'docs']) { const dirPath = path.join(ROOT, dir); if (!fs.existsSync(dirPath)) continue; for (const slug of fs.readdirSync(dirPath)) { const mdPath = path.join(dirPath, slug, 'index.md'); if (fs.existsSync(mdPath)) { articles.push({ slug, mdPath, category: dir }); } } } return articles; } // --- Frontmatter update --- function ensureFrontmatterImage(mdPath) { const raw = fs.readFileSync(mdPath, 'utf-8'); const parsed = matter(raw); if (parsed.data.image) return false; // already set parsed.data.image = './banner.png'; const updated = matter.stringify(parsed.content, parsed.data); fs.writeFileSync(mdPath, updated); return true; } // --- Main --- function generateOne(slug, force = false) { const article = findArticle(slug); if (!article) { console.error(`❌ Article not found: ${slug}`); return false; } const { mdPath, category } = article; const bannerPath = path.join(path.dirname(mdPath), 'banner.png'); if (fs.existsSync(bannerPath) && !force) { console.log(`⏭️ Skip (exists): ${slug}`); return true; } const parsed = matter(fs.readFileSync(mdPath, 'utf-8')); const title = parsed.data.title || slug; const buf = drawBanner(title, category); fs.writeFileSync(bannerPath, buf); const updated = ensureFrontmatterImage(mdPath); console.log(`✅ ${slug} → banner.png (${(buf.length / 1024).toFixed(0)}KB)${updated ? ' + frontmatter updated' : ''}`); return true; } // --- CLI --- const args = process.argv.slice(2); const force = args.includes('--force'); const all = args.includes('--all'); if (all) { const articles = getAllArticles(); console.log(`Found ${articles.length} articles`); let generated = 0; for (const { slug } of articles) { if (generateOne(slug, force)) generated++; } console.log(`\nDone: ${generated}/${articles.length}`); } else { const slug = args.find(a => !a.startsWith('--')); if (!slug) { console.log('Usage: node scripts/generate-banner.js | --all [--force]'); process.exit(1); } generateOne(slug, force); }