010e8d7ffc
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
163 lines
18 KiB
JavaScript
163 lines
18 KiB
JavaScript
// Public blog (Marty, 2026-09-12): coaching and teaching articles on linkspin-test.saasy.top, written in
|
||
// Admin > Blog, server-rendered so crawlers see real HTML with real metadata. Storage: MySQL
|
||
// blog_posts, or DATA_DIR/blog.json. Public: /blog (paged index), /blog/<slug>, /blog/feed.xml,
|
||
// /sitemap.xml, /robots.txt. SEO per post: title, description, canonical, Open Graph, Twitter
|
||
// card, article dates, BlogPosting JSON-LD, breadcrumb JSON-LD, internal links, related posts.
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const db = require('./db');
|
||
let DATA_DIR = null;
|
||
const SITE = 'https://linkspin-test.saasy.top';
|
||
const AUTHOR = 'Marty Bostick';
|
||
const PER_PAGE = 10;
|
||
|
||
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||
const slugify = s => String(s || '').toLowerCase().replace(/['’]/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80);
|
||
const words = html => String(html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||
const readMinutes = html => Math.max(1, Math.round(words(html).split(' ').length / 220));
|
||
const fmtDate = ts => new Date(ts).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'America/Chicago' });
|
||
|
||
// article HTML from the admin editor: keep a small whitelist of tags, http(s) links and safe images
|
||
function sanitize(html) {
|
||
const src = String(html || '').replace(/ /g, ' ').replace(/<(script|style|iframe|object|embed|form)\b[\s\S]*?<\/\1\s*>/gi, '').replace(/<(script|style|iframe|object|embed|form|input)\b[^>]*>/gi, '').replace(/<!--[\s\S]*?-->/g, '').replace(/\son[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '');
|
||
const ALLOW = new Set(['p', 'br', 'hr', 'h2', 'h3', 'h4', 'ul', 'ol', 'li', 'b', 'strong', 'i', 'em', 'u', 'a', 'img', 'blockquote', 'pre', 'code', 'figure', 'figcaption', 'table', 'thead', 'tbody', 'tr', 'th', 'td']);
|
||
const safeSrc = s => /^(\/uploads\/[a-z0-9]{24}\.(png|jpg|webp|gif)|\/banners\/[a-z0-9._-]+\.(png|jpg|webp|gif)|https:\/\/[^\s"'<>]+)$/i.test(s);
|
||
return src.replace(/<\s*(\/?)\s*([a-zA-Z0-9]+)((?:[^>"']|"[^"]*"|'[^']*')*)>/g, (m, close, tag, attrs) => {
|
||
tag = tag.toLowerCase();
|
||
if (!ALLOW.has(tag)) return '';
|
||
if (close) return '</' + tag + '>';
|
||
if (tag === 'a') {
|
||
const hm = /href\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(attrs || ''); const href = (hm && (hm[1] || hm[2])) || '';
|
||
if (!/^(https?:\/\/|\/)[^\s"'<>]*$/i.test(href)) return '';
|
||
const internal = href.startsWith('/') || href.startsWith(SITE);
|
||
return '<a href="' + href.replace(/"/g, '%22') + '"' + (internal ? '' : ' target="_blank" rel="noopener"') + '>';
|
||
}
|
||
if (tag === 'img') {
|
||
const sm = /src\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(attrs || ''); const s = (sm && (sm[1] || sm[2])) || '';
|
||
const am = /alt\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(attrs || ''); const alt = (am && (am[1] || am[2])) || '';
|
||
return safeSrc(s) ? '<img src="' + s.replace(/"/g, '%22') + '" alt="' + esc(alt) + '" loading="lazy">' : '';
|
||
}
|
||
if (tag === 'br' || tag === 'hr') return '<' + tag + '>';
|
||
return '<' + tag + '>';
|
||
});
|
||
}
|
||
|
||
// ---- storage ----
|
||
const J = {
|
||
db: { v: 1, posts: {} },
|
||
FILE() { return path.join(DATA_DIR, 'blog.json'); },
|
||
load() { try { this.db = Object.assign(this.db, JSON.parse(fs.readFileSync(this.FILE(), 'utf8'))); } catch (e) {} },
|
||
save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} },
|
||
async all() { return Object.values(this.db.posts); },
|
||
async get(slug) { return this.db.posts[slug] || null; },
|
||
async put(p) { this.db.posts[p.slug] = p; this.save(); return p; },
|
||
async remove(slug) { delete this.db.posts[slug]; this.save(); },
|
||
async bumpViews(slug) { const p = this.db.posts[slug]; if (p) { p.views = (p.views || 0) + 1; this.save(); } }
|
||
};
|
||
const D = {
|
||
async all() { return (await db.q('SELECT * FROM blog_posts ORDER BY COALESCE(published_at, created) DESC')).map(row); },
|
||
async get(slug) { const r = await db.q('SELECT * FROM blog_posts WHERE slug=?', [slug]); return r[0] ? row(r[0]) : null; },
|
||
async put(p) {
|
||
await db.q(`INSERT INTO blog_posts (slug,title,excerpt,body,cover,tags,status,author,created,updated,published_at,views) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
||
ON DUPLICATE KEY UPDATE title=VALUES(title), excerpt=VALUES(excerpt), body=VALUES(body), cover=VALUES(cover), tags=VALUES(tags), status=VALUES(status), author=VALUES(author), updated=VALUES(updated), published_at=VALUES(published_at)`,
|
||
[p.slug, p.title, p.excerpt, p.body, p.cover || null, (p.tags || []).join(','), p.status, p.author, p.created, p.updated, p.publishedAt || null, p.views || 0]);
|
||
return this.get(p.slug);
|
||
},
|
||
async remove(slug) { await db.q('DELETE FROM blog_posts WHERE slug=?', [slug]); },
|
||
async bumpViews(slug) { await db.q('UPDATE blog_posts SET views=views+1 WHERE slug=?', [slug]); }
|
||
};
|
||
const row = r => ({ slug: r.slug, title: r.title, excerpt: r.excerpt || '', body: r.body || '', cover: r.cover || '', tags: r.tags ? String(r.tags).split(',').filter(Boolean) : [], status: r.status, author: r.author || AUTHOR, created: Number(r.created), updated: Number(r.updated), publishedAt: r.published_at ? Number(r.published_at) : null, views: Number(r.views) || 0 });
|
||
const impl = () => db.enabled() ? D : J;
|
||
|
||
function init(opts) { DATA_DIR = opts.dataDir; if (!db.enabled()) J.load(); }
|
||
async function listAll() { return (await impl().all()).sort((a, b) => (b.publishedAt || b.created) - (a.publishedAt || a.created)); }
|
||
async function listPublished() { return (await listAll()).filter(p => p.status === 'published' && (p.publishedAt || 0) <= Date.now()); }
|
||
async function get(slug) { return impl().get(slugify(slug)); }
|
||
async function save(input, existingSlug) {
|
||
const title = String(input.title || '').trim().slice(0, 140);
|
||
if (!title) return { error: 'Give the post a title.' };
|
||
let slug = slugify(input.slug || title); if (!slug) return { error: 'Slug needs letters or numbers.' };
|
||
const prev = existingSlug ? await impl().get(existingSlug) : null;
|
||
if (!prev && await impl().get(slug)) return { error: 'That slug is already used. Pick another.' };
|
||
if (prev && prev.slug !== slug) { if (await impl().get(slug)) return { error: 'That slug is already used.' }; await impl().remove(prev.slug); }
|
||
const now = Date.now();
|
||
const status = input.status === 'published' ? 'published' : 'draft';
|
||
const body = sanitize(input.body);
|
||
const excerpt = String(input.excerpt || '').trim().slice(0, 300) || words(body).slice(0, 200);
|
||
const post = { slug, title, excerpt, body, cover: /^(\/uploads\/|\/banners\/|https:\/\/)/.test(String(input.cover || '')) ? String(input.cover).slice(0, 300) : '', tags: String(input.tags || '').split(',').map(t => t.trim().toLowerCase()).filter(Boolean).slice(0, 8),
|
||
status, author: AUTHOR, created: prev ? prev.created : now, updated: now,
|
||
publishedAt: status === 'published' ? (prev && prev.publishedAt ? prev.publishedAt : (input.publishedAt ? Number(new Date(input.publishedAt)) || now : now)) : (prev ? prev.publishedAt : null), views: prev ? prev.views : 0 };
|
||
return { ok: true, post: await impl().put(post) };
|
||
}
|
||
async function remove(slug) { await impl().remove(slugify(slug)); return { ok: true }; }
|
||
async function bumpViews(slug) { try { await impl().bumpViews(slug); } catch (e) {} }
|
||
|
||
// ---- rendering ----
|
||
function head(o) {
|
||
const url = SITE + o.path;
|
||
const img = o.image ? (o.image.startsWith('http') ? o.image : SITE + o.image) : SITE + '/banners/iap-hero-1200x630.png';
|
||
return '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
|
||
+ '<title>' + esc(o.title) + '</title><meta name="description" content="' + esc(o.desc) + '"><link rel="canonical" href="' + esc(url) + '">'
|
||
+ '<meta name="robots" content="index,follow,max-image-preview:large"><meta name="theme-color" content="#043b2f">'
|
||
+ '<meta property="og:type" content="' + (o.article ? 'article' : 'website') + '"><meta property="og:site_name" content="LinkSpin"><meta property="og:title" content="' + esc(o.title) + '"><meta property="og:description" content="' + esc(o.desc) + '"><meta property="og:url" content="' + esc(url) + '"><meta property="og:image" content="' + esc(img) + '">'
|
||
+ (o.article ? '<meta property="article:published_time" content="' + new Date(o.article.publishedAt || o.article.created).toISOString() + '"><meta property="article:modified_time" content="' + new Date(o.article.updated).toISOString() + '"><meta property="article:author" content="' + esc(AUTHOR) + '">' + (o.article.tags || []).map(t => '<meta property="article:tag" content="' + esc(t) + '">').join('') : '')
|
||
+ '<meta name="twitter:card" content="summary_large_image"><meta name="twitter:title" content="' + esc(o.title) + '"><meta name="twitter:description" content="' + esc(o.desc) + '"><meta name="twitter:image" content="' + esc(img) + '">'
|
||
+ '<link rel="alternate" type="application/rss+xml" title="LinkSpin blog" href="' + SITE + '/blog/feed.xml"><link rel="icon" type="image/png" href="/logo-icon.png">'
|
||
+ '<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap"><link rel="stylesheet" href="/assets/site.css?v=20260912b">'
|
||
+ '<style>.bl{max-width:760px}.bl h1{font-size:clamp(30px,4.6vw,44px);line-height:1.12;margin:10px 0 12px}.bl .meta{color:var(--muted);font-size:14px;margin:0 0 22px}.bl .cover{width:100%;border-radius:14px;border:1px solid var(--line);margin:0 0 26px;display:block}.bl article{font-size:17.5px;line-height:1.7}.bl article p{margin:0 0 18px;max-width:68ch}.bl article h2{font-size:26px;margin:36px 0 12px}.bl article h3{font-size:20px;margin:28px 0 10px}.bl article ul,.bl article ol{margin:0 0 18px 22px;max-width:66ch}.bl article li{margin:6px 0}.bl article blockquote{border-left:4px solid var(--mint);margin:0 0 18px;padding:8px 18px;color:var(--muted);font-style:italic}.bl article img{max-width:100%;border-radius:12px;border:1px solid var(--line)}.bl article a{color:var(--mint)}.bl article pre{background:rgba(4,8,7,.6);border:1px solid var(--line);border-radius:10px;padding:14px;overflow:auto;font-size:14px}.bl article table{border-collapse:collapse;width:100%;font-size:15px}.bl article th,.bl article td{border-bottom:1px solid var(--line);padding:8px 10px;text-align:left}.tags a{display:inline-block;font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--mint);border:1px solid rgba(67,232,195,.4);border-radius:999px;padding:3px 10px;margin:0 6px 6px 0;text-decoration:none}.post-card{display:block;background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:20px 22px;margin:0 0 14px;color:inherit;text-decoration:none}.post-card:hover{border-color:var(--mint)}.post-card h2{font-size:22px;margin:0 0 6px}.post-card p{margin:0;color:var(--muted);font-size:15px;max-width:70ch}.post-card .meta{margin:8px 0 0;font-size:13px}.author{display:flex;gap:14px;align-items:center;border:1px solid var(--line);border-radius:14px;padding:16px 18px;margin:36px 0 0;background:var(--panel)}.author img{width:56px;height:56px;border-radius:50%;object-fit:cover}.author b{display:block}.author span{color:var(--muted);font-size:14px}.share a{margin-right:14px;font-size:14px}.pager{display:flex;justify-content:space-between;margin:26px 0}.related{margin-top:40px}.related h3{margin-bottom:10px}</style>'
|
||
+ '</head><body><div class="wrap bl">';
|
||
}
|
||
function tail() {
|
||
return '</div><script src="/assets/common.js?v=20260914a"></script><script src="/assets/blog-page.js?v=20260914a"></script></body></html>';
|
||
}
|
||
const authorBox = () => '<div class="author"><img src="/logo-icon.png" alt="LinkSpin"><div><b>' + AUTHOR + '</b><span>Founder of LinkSpin and the Crypto Team Build Network. Twenty-plus years of internet marketing, and a habit of writing down what actually worked.</span></div></div>';
|
||
const cardHtml = p => '<a class="post-card" href="/blog/' + esc(p.slug) + '">' + (p.cover ? '<img src="' + esc(p.cover) + '" alt="" loading="lazy" style="width:100%;border-radius:10px;margin:0 0 12px">' : '') + '<h2>' + esc(p.title) + '</h2><p>' + esc(p.excerpt) + '</p><p class="meta">' + fmtDate(p.publishedAt || p.created) + ' · ' + readMinutes(p.body) + ' min read' + (p.tags.length ? ' · ' + p.tags.map(esc).join(', ') : '') + '</p></a>';
|
||
|
||
function renderIndex(posts, page, tag) {
|
||
const all = tag ? posts.filter(p => p.tags.includes(tag)) : posts;
|
||
const pages = Math.max(1, Math.ceil(all.length / PER_PAGE)); page = Math.min(Math.max(1, page || 1), pages);
|
||
const slice = all.slice((page - 1) * PER_PAGE, page * PER_PAGE);
|
||
const title = (tag ? esc(tag) + ' · ' : '') + 'Blog | LinkSpin';
|
||
const desc = 'Coaching and teaching articles from Marty Bostick on building a line, advertising that pays, and doing the simple work every day.';
|
||
const p = tag ? '/blog/tag/' + encodeURIComponent(tag) : '/blog' + (page > 1 ? '/page/' + page : '');
|
||
let h = head({ title, desc, path: p });
|
||
h += '<section class="hero" style="padding:56px 0 8px"><p class="eyebrow">' + (tag ? 'Tag: ' + esc(tag) : 'The LinkSpin blog') + '</p><h1>Notes on building a line, <em>one honest day at a time</em>.</h1><p class="lead">' + esc(desc) + '</p></section>';
|
||
h += slice.length ? slice.map(cardHtml).join('') : '<p class="muted">Nothing published yet. Check back soon.</p>';
|
||
if (pages > 1) h += '<div class="pager">' + (page > 1 ? '<a href="/blog' + (page - 1 > 1 ? '/page/' + (page - 1) : '') + '">← Newer</a>' : '<span></span>') + (page < pages ? '<a href="/blog/page/' + (page + 1) + '">Older →</a>' : '<span></span>') + '</div>';
|
||
h += '<script type="application/ld+json">' + JSON.stringify({ '@context': 'https://schema.org', '@type': 'Blog', name: 'LinkSpin blog', url: SITE + '/blog', publisher: { '@type': 'Organization', name: 'LinkSpin', logo: SITE + '/logo.png' } }) + '</script>';
|
||
return h + tail();
|
||
}
|
||
function renderPost(p, related) {
|
||
const url = SITE + '/blog/' + p.slug;
|
||
let h = head({ title: p.title + ' | LinkSpin', desc: p.excerpt, path: '/blog/' + p.slug, image: p.cover, article: p });
|
||
h += '<p class="eyebrow" style="margin-top:44px"><a href="/blog" style="color:var(--mint);text-decoration:none">Blog</a>' + (p.tags[0] ? ' · <a href="/blog/tag/' + encodeURIComponent(p.tags[0]) + '" style="color:var(--mint);text-decoration:none">' + esc(p.tags[0]) + '</a>' : '') + '</p>';
|
||
h += '<h1>' + esc(p.title) + '</h1><p class="meta">By ' + esc(AUTHOR) + ' · ' + fmtDate(p.publishedAt || p.created) + ' · ' + readMinutes(p.body) + ' min read</p>';
|
||
if (p.cover) h += '<img class="cover" src="' + esc(p.cover) + '" alt="' + esc(p.title) + '">';
|
||
h += '<article>' + p.body + '</article>';
|
||
if (p.tags.length) h += '<p class="tags" style="margin-top:22px">' + p.tags.map(t => '<a href="/blog/tag/' + encodeURIComponent(t) + '">' + esc(t) + '</a>').join('') + '</p>';
|
||
h += '<p class="share" style="margin-top:18px"><a href="https://x.com/intent/tweet?url=' + encodeURIComponent(url) + '&text=' + encodeURIComponent(p.title) + '" target="_blank" rel="noopener">Share on X</a><a href="https://www.facebook.com/sharer/sharer.php?u=' + encodeURIComponent(url) + '" target="_blank" rel="noopener">Share on Facebook</a><a href="https://t.me/share/url?url=' + encodeURIComponent(url) + '&text=' + encodeURIComponent(p.title) + '" target="_blank" rel="noopener">Telegram</a></p>';
|
||
h += authorBox();
|
||
if (related.length) h += '<div class="related"><h3>Keep reading</h3>' + related.map(cardHtml).join('') + '</div>';
|
||
h += '<p class="muted small" style="margin-top:34px">LinkSpin sells advertising. Nothing here is investment advice, no income is guaranteed, and cryptocurrency involves risk of loss.</p>';
|
||
h += '<script type="application/ld+json">' + JSON.stringify({ '@context': 'https://schema.org', '@type': 'BlogPosting', headline: p.title, description: p.excerpt, image: p.cover ? (p.cover.startsWith('http') ? p.cover : SITE + p.cover) : SITE + '/banners/iap-hero-1200x630.png', datePublished: new Date(p.publishedAt || p.created).toISOString(), dateModified: new Date(p.updated).toISOString(), wordCount: words(p.body).split(' ').length, keywords: p.tags.join(', '), author: { '@type': 'Person', name: AUTHOR, url: SITE + '/blog' }, publisher: { '@type': 'Organization', name: 'LinkSpin', logo: { '@type': 'ImageObject', url: SITE + '/logo.png' } }, mainEntityOfPage: { '@type': 'WebPage', '@id': url } }) + '</script>';
|
||
h += '<script type="application/ld+json">' + JSON.stringify({ '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: [{ '@type': 'ListItem', position: 1, name: 'Blog', item: SITE + '/blog' }, { '@type': 'ListItem', position: 2, name: p.title, item: url }] }) + '</script>';
|
||
return h + tail();
|
||
}
|
||
function relatedFor(p, posts) {
|
||
const scored = posts.filter(x => x.slug !== p.slug).map(x => ({ x, s: x.tags.filter(t => p.tags.includes(t)).length }));
|
||
return scored.sort((a, b) => b.s - a.s || (b.x.publishedAt || 0) - (a.x.publishedAt || 0)).slice(0, 3).map(r => r.x);
|
||
}
|
||
function rss(posts) {
|
||
const items = posts.slice(0, 30).map(p => '<item><title>' + esc(p.title) + '</title><link>' + SITE + '/blog/' + p.slug + '</link><guid>' + SITE + '/blog/' + p.slug + '</guid><pubDate>' + new Date(p.publishedAt || p.created).toUTCString() + '</pubDate><description>' + esc(p.excerpt) + '</description></item>').join('');
|
||
return '<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>LinkSpin blog</title><link>' + SITE + '/blog</link><description>Coaching and teaching articles from Marty Bostick.</description>' + items + '</channel></rss>';
|
||
}
|
||
function sitemap(posts) {
|
||
const pages = ['/', '/blog', '/whats-new', '/leaderboard', '/ledger', '/contract', '/plays', '/wallets', '/earning', '/partners'];
|
||
const u = pages.map(p => '<url><loc>' + SITE + p + '</loc><changefreq>weekly</changefreq></url>').join('')
|
||
+ posts.map(p => '<url><loc>' + SITE + '/blog/' + p.slug + '</loc><lastmod>' + new Date(p.updated).toISOString().slice(0, 10) + '</lastmod><changefreq>monthly</changefreq></url>').join('');
|
||
return '<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' + u + '</urlset>';
|
||
}
|
||
const robots = () => 'User-agent: *\nAllow: /\nDisallow: /my\nDisallow: /admin\nDisallow: /api/\nDisallow: /view/\nSitemap: ' + SITE + '/sitemap.xml\n';
|
||
|
||
module.exports = { init, listAll, listPublished, get, save, remove, bumpViews, renderIndex, renderPost, relatedFor, rss, sitemap, robots, slugify, sanitize };
|