All checks were successful
Deploy Docusaurus Site / deploy (push) Successful in 28s
219 lines
6.8 KiB
JavaScript
219 lines
6.8 KiB
JavaScript
#!/usr/bin/env node
|
|
// Banner image generator for docs.techswan.online
|
|
// Usage:
|
|
// node scripts/generate-banner.js <slug> # 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 <slug> | --all [--force]');
|
|
process.exit(1);
|
|
}
|
|
generateOne(slug, force);
|
|
}
|