Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

add og:image social cards for news articles via oven

- news layout emits og:title, og:description, og:image, twitter:card meta tags
- renderItemPage passes og metadata (title, description, image URL) to layout
- new oven endpoint /news-og/:code.png generates branded 1200x630 PNG cards
- cards show title, @handle, date on dark gradient with "Aesthetic News" branding
- images cached in DigitalOcean Spaces with 7-day TTL

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+228 -5
+126
oven/grabber.mjs
··· 4715 4715 } 4716 4716 } 4717 4717 4718 + // ============================================================================= 4719 + // NEWS OG IMAGE GENERATION 4720 + // ============================================================================= 4721 + 4722 + // News OG image cache (memory, keyed by post code) 4723 + const newsOGCache = new Map(); 4724 + 4725 + /** 4726 + * Generate a branded OG image for a news.aesthetic.computer article (1200x630 PNG). 4727 + * Shows title, author handle, and "Aesthetic News" branding. 4728 + */ 4729 + export async function generateNewsOGImage(post, forceRegenerate = false) { 4730 + const code = post.code; 4731 + console.log(`\n📰 News OG Image Request: ${code} (force: ${forceRegenerate})`); 4732 + 4733 + // Check memory cache. 4734 + if (!forceRegenerate) { 4735 + const cached = newsOGCache.get(code); 4736 + if (cached && Date.now() < cached.expires) { 4737 + return { url: cached.url, cached: true }; 4738 + } 4739 + } 4740 + 4741 + // Check Spaces cache. 4742 + const key = `og/news/${code}.png`; 4743 + if (!forceRegenerate) { 4744 + try { 4745 + await spacesClient.send(new HeadObjectCommand({ Bucket: SPACES_BUCKET, Key: key })); 4746 + const url = `${SPACES_CDN_BASE}/${key}`; 4747 + newsOGCache.set(code, { url, expires: Date.now() + 60 * 60 * 1000 }); 4748 + return { url, cached: true }; 4749 + } catch { 4750 + // Not cached, generate below. 4751 + } 4752 + } 4753 + 4754 + const W = 1200; 4755 + const H = 630; 4756 + 4757 + // Escape XML entities for SVG. 4758 + const esc = (s) => 4759 + (s || "") 4760 + .replace(/&/g, "&amp;") 4761 + .replace(/</g, "&lt;") 4762 + .replace(/>/g, "&gt;") 4763 + .replace(/"/g, "&quot;"); 4764 + 4765 + const title = esc(post.title || "(untitled)"); 4766 + const handle = esc(post.handle || "@anon"); 4767 + const date = post.when ? new Date(post.when).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" }) : ""; 4768 + 4769 + // Word-wrap title into lines that fit within the card. 4770 + const maxCharsPerLine = 32; 4771 + const words = title.split(/\s+/); 4772 + const lines = []; 4773 + let currentLine = ""; 4774 + for (const word of words) { 4775 + if (currentLine && (currentLine + " " + word).length > maxCharsPerLine) { 4776 + lines.push(currentLine); 4777 + currentLine = word; 4778 + } else { 4779 + currentLine = currentLine ? currentLine + " " + word : word; 4780 + } 4781 + } 4782 + if (currentLine) lines.push(currentLine); 4783 + // Limit to 5 lines max. 4784 + if (lines.length > 5) { 4785 + lines.length = 5; 4786 + lines[4] = lines[4].slice(0, maxCharsPerLine - 3) + "..."; 4787 + } 4788 + 4789 + const titleFontSize = lines.length <= 2 ? 52 : lines.length <= 3 ? 44 : 36; 4790 + const titleLineHeight = titleFontSize * 1.3; 4791 + const titleBlockHeight = lines.length * titleLineHeight; 4792 + const titleStartY = (H - titleBlockHeight) / 2 + titleFontSize * 0.8; 4793 + 4794 + const titleLines = lines 4795 + .map((line, i) => `<text x="${W / 2}" y="${titleStartY + i * titleLineHeight}" font-family="monospace" font-size="${titleFontSize}" font-weight="bold" fill="#e8e4de" text-anchor="middle">${line}</text>`) 4796 + .join("\n "); 4797 + 4798 + const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}"> 4799 + <defs> 4800 + <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1"> 4801 + <stop offset="0%" stop-color="#1a1a2e"/> 4802 + <stop offset="100%" stop-color="#16213e"/> 4803 + </linearGradient> 4804 + </defs> 4805 + 4806 + <!-- Background --> 4807 + <rect width="${W}" height="${H}" fill="url(#bg)"/> 4808 + 4809 + <!-- Subtle border --> 4810 + <rect x="24" y="24" width="${W - 48}" height="${H - 48}" rx="8" fill="none" stroke="rgba(200,200,220,0.1)" stroke-width="1"/> 4811 + 4812 + <!-- Aesthetic News branding --> 4813 + <text x="60" y="72" font-family="monospace" font-size="18" font-weight="bold" fill="rgba(200,200,220,0.5)" letter-spacing="2">AESTHETIC NEWS</text> 4814 + 4815 + <!-- Title --> 4816 + ${titleLines} 4817 + 4818 + <!-- Author + date footer --> 4819 + <text x="60" y="${H - 45}" font-family="monospace" font-size="18" fill="rgba(180,180,200,0.6)">${handle}${date ? ` · ${esc(date)}` : ""}</text> 4820 + <text x="${W - 60}" y="${H - 45}" font-family="monospace" font-size="16" fill="rgba(180,180,200,0.35)" text-anchor="end">news.aesthetic.computer</text> 4821 + </svg>`; 4822 + 4823 + const buffer = await sharp(Buffer.from(svg)).png().toBuffer(); 4824 + 4825 + // Upload to Spaces. 4826 + await spacesClient.send( 4827 + new PutObjectCommand({ 4828 + Bucket: SPACES_BUCKET, 4829 + Key: key, 4830 + Body: buffer, 4831 + ContentType: "image/png", 4832 + ACL: "public-read", 4833 + CacheControl: "public, max-age=604800", 4834 + }), 4835 + ); 4836 + 4837 + const url = `${SPACES_CDN_BASE}/${key}`; 4838 + newsOGCache.set(code, { url, expires: Date.now() + 7 * 24 * 60 * 60 * 1000 }); 4839 + 4840 + console.log(`📤 News OG image uploaded: ${url} (${(buffer.length / 1024).toFixed(1)} KB)`); 4841 + return { url, cached: false, buffer }; 4842 + } 4843 + 4718 4844 export { IPFS_GATEWAY };
+71 -1
oven/server.mjs
··· 11 11 import { gunzipSync, gzipSync } from 'node:zlib'; 12 12 import { WebSocketServer } from 'ws'; 13 13 import { healthHandler, bakeHandler, statusHandler, bakeCompleteHandler, bakeStatusHandler, getActiveBakes, getIncomingBakes, getRecentBakes, subscribeToUpdates, cleanupStaleBakes } from './baker.mjs'; 14 - import { grabHandler, grabGetHandler, grabIPFSHandler, grabPiece, getCachedOrGenerate, getActiveGrabs, getRecentGrabs, getLatestKeepThumbnail, ensureLatestKeepThumbnail, getLatestIPFSUpload, getAllLatestIPFSUploads, setNotifyCallback, setLogCallback, cleanupStaleGrabs, clearAllActiveGrabs, getQueueStatus, getCurrentProgress, getAllProgress, getConcurrencyStatus, IPFS_GATEWAY, generateKidlispOGImage, getOGImageCacheStatus, getFrozenPieces, clearFrozenPiece, getLatestOGImageUrl, regenerateOGImagesBackground, generateKidlispBackdrop, getLatestBackdropUrl, APP_SCREENSHOT_PRESETS, generateNotepatOGImage, getLatestNotepatOGUrl, prewarmGrabBrowser } from './grabber.mjs'; 14 + import { grabHandler, grabGetHandler, grabIPFSHandler, grabPiece, getCachedOrGenerate, getActiveGrabs, getRecentGrabs, getLatestKeepThumbnail, ensureLatestKeepThumbnail, getLatestIPFSUpload, getAllLatestIPFSUploads, setNotifyCallback, setLogCallback, cleanupStaleGrabs, clearAllActiveGrabs, getQueueStatus, getCurrentProgress, getAllProgress, getConcurrencyStatus, IPFS_GATEWAY, generateKidlispOGImage, getOGImageCacheStatus, getFrozenPieces, clearFrozenPiece, getLatestOGImageUrl, regenerateOGImagesBackground, generateKidlispBackdrop, getLatestBackdropUrl, APP_SCREENSHOT_PRESETS, generateNotepatOGImage, getLatestNotepatOGUrl, prewarmGrabBrowser, generateNewsOGImage } from './grabber.mjs'; 15 15 import archiver from 'archiver'; 16 16 import sharp from 'sharp'; 17 17 import { createBundle, createJSPieceBundle, createM4DBundle, generateDeviceHTML, prewarmCache, getCacheStatus, setSkipMinification } from './bundler.mjs'; ··· 1108 1108 <div><a href="/kidlisp-og/preview">/kidlisp-og/preview</a><span class="desc">OG preview</span></div> 1109 1109 <div><a href="/og-preview">/og-preview</a><span class="desc">OG preview (alt)</span></div> 1110 1110 <div><a href="/notepat-og.png">/notepat-og.png</a><span class="desc">Notepat OG image</span></div> 1111 + <div><a href="/news-og/ncd2.png">/news-og/:code.png</a><span class="desc">News article OG image</span></div> 1111 1112 <div><a href="/kidlisp-backdrop.webp">/kidlisp-backdrop.webp</a><span class="desc">KidLisp backdrop animation</span></div> 1112 1113 <div><a href="/kidlisp-backdrop">/kidlisp-backdrop</a><span class="desc">KidLisp backdrop page</span></div> 1113 1114 </div> ··· 1886 1887 error: 'Failed to generate Notepat OG image', 1887 1888 message: error.message 1888 1889 }); 1890 + } 1891 + }); 1892 + 1893 + // ============================================================================= 1894 + // News OG Image - Dynamic social cards for news.aesthetic.computer articles 1895 + // ============================================================================= 1896 + 1897 + app.get('/news-og/:code.png', async (req, res) => { 1898 + try { 1899 + const code = req.params.code; 1900 + const force = req.query.force === 'true'; 1901 + 1902 + addServerLog('info', '📰', `News OG request: ${code}${force ? ' (force)' : ''}`); 1903 + 1904 + // Fetch post from MongoDB. 1905 + const mongoUri = process.env.MONGODB_CONNECTION_STRING; 1906 + const dbName = process.env.MONGODB_NAME; 1907 + if (!mongoUri || !dbName) { 1908 + return res.status(500).json({ error: 'MongoDB not configured' }); 1909 + } 1910 + 1911 + const client = new MongoClient(mongoUri); 1912 + await client.connect(); 1913 + const db = client.db(dbName); 1914 + const post = await db.collection('news-posts').findOne({ code, status: { $ne: 'dead' } }); 1915 + 1916 + if (!post) { 1917 + await client.close(); 1918 + return res.status(404).json({ error: 'Post not found' }); 1919 + } 1920 + 1921 + // Hydrate handle. 1922 + if (post.user) { 1923 + const handleDoc = await db.collection('@handles').findOne({ _id: post.user }); 1924 + post.handle = handleDoc ? `@${handleDoc.handle}` : '@anon'; 1925 + } else { 1926 + post.handle = '@anon'; 1927 + } 1928 + await client.close(); 1929 + 1930 + const result = await generateNewsOGImage(post, force); 1931 + 1932 + if (result.cached && result.url) { 1933 + addServerLog('success', '📦', `News OG cache hit: ${code} → proxying`); 1934 + try { 1935 + const cdnResponse = await fetch(result.url); 1936 + if (!cdnResponse.ok) throw new Error(`CDN fetch failed: ${cdnResponse.status}`); 1937 + const buffer = Buffer.from(await cdnResponse.arrayBuffer()); 1938 + res.setHeader('Content-Type', 'image/png'); 1939 + res.setHeader('Content-Length', buffer.length); 1940 + res.setHeader('Cache-Control', 'public, max-age=604800'); 1941 + res.setHeader('X-Cache', 'HIT'); 1942 + return res.send(buffer); 1943 + } catch (fetchErr) { 1944 + addServerLog('warn', '⚠️', `News OG proxy failed: ${fetchErr.message}`); 1945 + return res.redirect(301, result.url); 1946 + } 1947 + } 1948 + 1949 + addServerLog('success', '🎨', `News OG generated: ${code}`); 1950 + res.setHeader('Content-Type', 'image/png'); 1951 + res.setHeader('Content-Length', result.buffer.length); 1952 + res.setHeader('Cache-Control', 'public, max-age=604800'); 1953 + res.setHeader('X-Cache', 'MISS'); 1954 + res.send(result.buffer); 1955 + } catch (error) { 1956 + console.error('News OG error:', error); 1957 + addServerLog('error', '❌', `News OG error: ${error.message}`); 1958 + res.status(500).json({ error: 'Failed to generate News OG image', message: error.message }); 1889 1959 } 1890 1960 }); 1891 1961
+31 -4
system/netlify/functions/news.mjs
··· 440 440 return host.startsWith("news.aesthetic.computer"); 441 441 } 442 442 443 - function layout({ title, body, assetBase, assetOrigin }) { 443 + function layout({ title, body, assetBase, assetOrigin, og }) { 444 444 const origin = assetOrigin || ""; 445 + const ogTags = og ? ` 446 + <meta property="og:title" content="${escapeHtml(og.title || title)}" /> 447 + <meta property="og:description" content="${escapeHtml(og.description || '')}" /> 448 + <meta property="og:image" content="${escapeHtml(og.image || '')}" /> 449 + <meta property="og:image:width" content="1200" /> 450 + <meta property="og:image:height" content="630" /> 451 + <meta property="og:url" content="${escapeHtml(og.url || '')}" /> 452 + <meta property="og:type" content="article" /> 453 + <meta name="twitter:card" content="summary_large_image" /> 454 + <meta name="twitter:title" content="${escapeHtml(og.title || title)}" /> 455 + <meta name="twitter:description" content="${escapeHtml(og.description || '')}" /> 456 + <meta name="twitter:image" content="${escapeHtml(og.image || '')}" />` : ''; 445 457 return `<!doctype html> 446 458 <html lang="en"> 447 459 <head> 448 460 <meta charset="utf-8" /> 449 461 <meta name="viewport" content="width=device-width, initial-scale=1" /> 450 - <title>${escapeHtml(title)}</title> 462 + <title>${escapeHtml(title)}</title>${ogTags} 451 463 <link rel="icon" href="${origin}${assetBase}/favicon.svg" type="image/svg+xml" /> 452 464 <link rel="stylesheet" href="https://aesthetic.computer/type/webfonts/berkeley-mono-variable.css"> 453 465 <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/lipis/flag-icons@7.0.0/css/flag-icons.min.css"> ··· 840 852 </main> 841 853 ${footer()}`; 842 854 843 - return { title: pageTitle, body }; 855 + // Build og metadata for social cards. 856 + const ogDescription = hydratedPost.text 857 + ? hydratedPost.text.slice(0, 200).replace(/\n/g, " ") + (hydratedPost.text.length > 200 ? "..." : "") 858 + : ""; 859 + const ogImage = `https://oven.aesthetic.computer/news-og/${encodeURIComponent(code)}.png`; 860 + const ogUrl = `https://news.aesthetic.computer/${code}`; 861 + const og = { 862 + title: hydratedPost.title || "(untitled)", 863 + description: ogDescription, 864 + image: ogImage, 865 + url: ogUrl, 866 + }; 867 + 868 + return { title: pageTitle, body, og }; 844 869 } 845 870 846 871 async function renderReportPage(basePath) { ··· 911 936 try { 912 937 database = await connectFn(); 913 938 let body; 939 + let og; 914 940 915 941 if (!route || route === "") { 916 942 title = "Aesthetic News"; ··· 935 961 const result = await renderItemPage(database, basePath, route); 936 962 body = result.body || result; 937 963 title = result.title || "Aesthetic News"; 964 + og = result.og; 938 965 } 939 966 940 - const html = layout({ title, body, assetBase, assetOrigin }); 967 + const html = layout({ title, body, assetBase, assetOrigin, og }); 941 968 await database.disconnect(); 942 969 return respondFn(200, html, { "Content-Type": "text/html" }); 943 970 } catch (error) {