import { promises as fs } from 'fs'; import { titleToCategory } from './titleToCategory.ts'; const C = { root: `${import.meta.dir}/..`, art: `${import.meta.dir}/../artikel`, base: 'https://dalam.web.id', limit: 30, // Limit ini SEKARANG HANYA dipakai untuk membatasi RSS & JSON Lite! cats: ['gaya-hidup', 'jejak-sejarah', 'lainnya', 'olah-media', 'opini-sosial', 'sistem-terbuka', 'warta-tekno'], hub: 'https://pubsubhubbub.appspot.com/' // 🔔 Hub WebSub publik (Google, masih conform ke spec 0.4 per Juli 2026) }; // --- FORMAT KATEGORI OTOMATIS --- const getCategoryLabel = (catSlug: string) => { return catSlug.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); }; // -------------------------------------------------- const BASE_RE = C.base.replace(/\./g, '\\.'); const decodeHTML = (str: string) => { const entities: Record = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ''': "'", '•': '•', '–': '–', '—': '—', ' ': ' ' }; let t = str.replace(/&[a-z0-9#]+;/gi, (m) => entities[m.toLowerCase()] || m); t = t.replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(parseInt(dec, 10))); t = t.replace(/&#x([a-f0-9]+);/gi, (_, hex) => String.fromCharCode(parseInt(hex, 16))); return t.trim(); }; const buildDate = new Date().toISOString().split('T')[0]; const slug = (t: any) => t.toString().toLowerCase().trim() .replace(/^[^\w\s]*/u, '') .replace(/ & /g, '-and-') .replace(/[^a-z0-9\s-]/g, '') .replace(/\s+/g, '-') .replace(/-+/g, '-'); const iso = (d: any) => { const parsed = new Date(d); const base = isNaN(parsed.getTime()) ? new Date() : parsed; return base.toISOString(); }; const sanitize = (r: string) => r.replace(/^\p{Emoji_Presentation}\s*/u, '').trim(); const mime = (u: string) => ({ png: 'image/png', webp: 'image/webp', svg: 'image/svg+xml' }[u.split('.').pop()!] || 'image/jpeg'); const escapeAttr = (s: string) => s.replace(/"/g, '"'); // 🖼️ dengan fallback bertingkat: -sm.webp (utama) → -md.webp → gambar asli → /thumbnail.webp (safety net terakhir) const imgWithFallback = (src: string, alt: string, extraAttrs: string = ''): string => { const smSrc = src.replace(/(\.[a-zA-Z0-9]+)$/, '-sm$1'); const mdSrc = src.replace(/(\.[a-zA-Z0-9]+)$/, '-md$1'); const onerror = `this.onerror=function(){this.onerror=function(){this.onerror=null;this.src='/thumbnail-sm.webp'};this.src='${src}'};this.src='${mdSrc}'`; return `${escapeAttr(alt)}`; }; const escapeXML = (s: string) => s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); const safeCDATA = (s: string) => s.replace(/]]>/g, ']]]]>'); const imgSize = async (url: string): Promise => { try { if (!url.startsWith(C.base)) return 0; return (await fs.stat(url.replace(C.base, C.root))).size; } catch { return 0; } }; const calculateFeedRootDate = (items: any[]): Date => { const now = new Date(); if (!items || items.length === 0) return now; const newestItemDate = new Date(items[0].lastmod); if (now.getTime() <= newestItemDate.getTime()) { const randomFeedOffset = Math.floor(Math.random() * (180000 - 60000 + 1)) + 60000; return new Date(newestItemDate.getTime() + randomFeedOffset); } return now; }; const buildRss = (t: string, items: any[], link: string, desc: string, sizes: Map = new Map()) => { const rootDate = calculateFeedRootDate(items); return `<![CDATA[${safeCDATA(decodeHTML(t))}]]>${escapeXML(C.base)}/id-ID${rootDate.toUTCString()}${items.map(it => `<![CDATA[${safeCDATA(decodeHTML(it.title))}]]>${escapeXML(it.loc)}${escapeXML(it.loc)}${new Date(it.lastmod).toUTCString()}` ).join('')}`; }; const buildAtom = (t: string, items: any[], feedUrl: string, desc: string, sizes: Map = new Map()) => { const rootDate = calculateFeedRootDate(items); return `${escapeXML(decodeHTML(t))}${escapeXML(desc)}${escapeXML(C.base)}/${rootDate.toISOString()}${items.map(it => `${escapeXML(decodeHTML(it.title))}${escapeXML(it.loc)}${it.lastmod}${it.lastmod}Fakhrul Rijal` ).join('')}`; }; const buildSitemapUrlset = (entries: string): string => `\n${entries}\n`; const buildSitemapIndex = (items: { loc: string; lastmod: string }[]): string => `\n${items.map(it => ` \n ${escapeXML(it.loc)}\n ${it.lastmod}\n `).join('')}\n`; const cleanupOldSitemaps = async (): Promise => { let removed = 0; const files = await fs.readdir(C.root).catch(() => []); for (const f of files) { if (/^sitemap-.+\.xml$/i.test(f)) { await fs.rm(`${C.root}/${f}`, { force: true }); removed++; } } for (const s of C.cats) { await fs.rm(`${C.root}/${s}.xml`, { force: true }); removed++; } return removed; }; const pingWebSub = async (feedUrls: string[]): Promise => { if (feedUrls.length === 0) { console.log('🔕 WebSub: nggak ada feed yang berubah, skip ping ke hub.'); return; } try { const body = new URLSearchParams(); body.append('hub.mode', 'publish'); feedUrls.forEach(u => body.append('hub.url', u)); const res = await fetch(C.hub, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), signal: AbortSignal.timeout(10_000) }); console.log(`📡 WebSub ping → ${C.hub} → HTTP ${res.status} (${feedUrls.length} feed berubah)`); feedUrls.forEach(u => console.log(` 🔗 ${u}`)); } catch (e: any) { console.log(`⚠️ WebSub ping gagal (dilewati, tidak fatal): ${e?.message || e}`); } }; const upsertManagedBlock = async (path: string, block: string): Promise => { const MARK_START = '# --- BEGIN LK-AUTOGEN (komposisi.ts — jangan edit manual) ---'; const MARK_END = '# --- END LK-AUTOGEN ---'; const existing = await Bun.file(path).text().catch(() => ''); const startIdx = existing.indexOf(MARK_START); const endIdx = existing.indexOf(MARK_END); let stripped = existing; if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) { stripped = existing.slice(0, startIdx) + existing.slice(endIdx + MARK_END.length); } stripped = stripped.trim(); const out = `${stripped ? stripped + '\n\n' : ''}${MARK_START}\n${block}\n${MARK_END}\n`; await Bun.write(path, out); }; const distribute = async ( f: string, cat: string, url: string, pre?: string, modTime?: string, prevData?: { url: string, title: string }, nextData?: { url: string, title: string } ) => { const dir = `${C.root}/${slug(cat)}`; await fs.mkdir(dir, { recursive: true }); let html = pre || await Bun.file(`${C.art}/${f}`).text(); const catLabel = getCategoryLabel(slug(cat)); const catSlug = slug(cat); const tags = catLabel.split(',').map(t => t.trim()).filter(Boolean); const tagsHtml = tags.map(t => ``).join('\n '); let prevNextTags = ''; if (prevData) { const safeTitle = escapeAttr(prevData.title); prevNextTags += `\n `; } if (nextData) { const safeTitle = escapeAttr(nextData.title); prevNextTags += `\n `; } const catRssUrl = `${C.base}/${catSlug}.rss`; const catAtomUrl = `${C.base}/${catSlug}.atom`; const feedLinkTags = `\n \n `; html = html .replace(/