// 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/, /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(//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 ''; 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 ''; } 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) ? '' + esc(alt) + '' : ''; } 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 '' + '' + esc(o.title) + '' + '' + '' + (o.article ? '' + (o.article.tags || []).map(t => '').join('') : '') + '' + '' + '' + '' + '
'; } function tail() { return '
'; } const authorBox = () => '
LinkSpin
' + AUTHOR + 'Founder of LinkSpin and the Crypto Team Build Network. Twenty-plus years of internet marketing, and a habit of writing down what actually worked.
'; const cardHtml = p => '
' + (p.cover ? '' : '') + '

' + esc(p.title) + '

' + esc(p.excerpt) + '

' + fmtDate(p.publishedAt || p.created) + ' · ' + readMinutes(p.body) + ' min read' + (p.tags.length ? ' · ' + p.tags.map(esc).join(', ') : '') + '

'; 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 += '

' + (tag ? 'Tag: ' + esc(tag) : 'The LinkSpin blog') + '

Notes on building a line, one honest day at a time.

' + esc(desc) + '

'; h += slice.length ? slice.map(cardHtml).join('') : '

Nothing published yet. Check back soon.

'; if (pages > 1) h += '
' + (page > 1 ? '← Newer' : '') + (page < pages ? 'Older →' : '') + '
'; h += ''; 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 += '

Blog' + (p.tags[0] ? ' · ' + esc(p.tags[0]) + '' : '') + '

'; h += '

' + esc(p.title) + '

By ' + esc(AUTHOR) + ' · ' + fmtDate(p.publishedAt || p.created) + ' · ' + readMinutes(p.body) + ' min read

'; if (p.cover) h += '' + esc(p.title) + ''; h += '
' + p.body + '
'; if (p.tags.length) h += '

' + p.tags.map(t => '' + esc(t) + '').join('') + '

'; h += '

Share on XShare on FacebookTelegram

'; h += authorBox(); if (related.length) h += ''; h += '

LinkSpin sells advertising. Nothing here is investment advice, no income is guaranteed, and cryptocurrency involves risk of loss.

'; h += ''; h += ''; 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 => '' + esc(p.title) + '' + SITE + '/blog/' + p.slug + '' + SITE + '/blog/' + p.slug + '' + new Date(p.publishedAt || p.created).toUTCString() + '' + esc(p.excerpt) + '').join(''); return 'LinkSpin blog' + SITE + '/blogCoaching and teaching articles from Marty Bostick.' + items + ''; } function sitemap(posts) { const pages = ['/', '/blog', '/whats-new', '/leaderboard', '/ledger', '/contract', '/plays', '/wallets', '/earning', '/partners']; const u = pages.map(p => '' + SITE + p + 'weekly').join('') + posts.map(p => '' + SITE + '/blog/' + p.slug + '' + new Date(p.updated).toISOString().slice(0, 10) + 'monthly').join(''); return '' + u + ''; } 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 };