Files
linkspin/blog.js
T

163 lines
18 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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) : '') + '">&larr; Newer</a>' : '<span></span>') + (page < pages ? '<a href="/blog/page/' + (page + 1) + '">Older &rarr;</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 };