SkateSpots/src/lib/newsletterArticle.ts
shakespeare.diy dc410106b3 Newsletter: subscriber count + admin archive backfill to Nostr
Two follow-ups to the Nostr-native newsletter (skipped auto-DM by design —
too spammy/fragile for now; the bell mailbox already covers the inbox feel).

Subscriber count (social proof):
- useNewsletterSubscriberCount tallies public NIP-78 subscription markers
  (deduped per author, counting current "true" state).
- Shown under the subscribe CTA as "Join N+ skaters getting the drops",
  hidden until it's meaningful (>=3) and rounded once it grows.

Archive backfill (consistency without losing the rich page):
- lib/newsletterArticle.ts converts a static NEWSLETTER_ISSUES entry into a
  NIP-23 (kind 30023) article — structured sections rendered to Markdown,
  stable slug `issue-N`, published_at from the issue date.
- NewsletterBackfillPanel (admin-only) diffs the static archive against what's
  already on-relay and publishes only the missing issues; safe to re-run,
  reports partial failures. The rich static page rendering is untouched.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-03 19:21:09 +07:00

101 lines
3 KiB
TypeScript

import { NEWSLETTER_KIND, NEWSLETTER_TAG } from '@/lib/types';
/**
* The subset of a static newsletter issue we need to turn it into a NIP-23
* long-form article (kind 30023). Kept structural (not importing the page) to
* avoid a page → lib → page import cycle.
*/
export interface StaticIssueLike {
number: number;
date: string; // ISO "YYYY-MM-DD"
vibe: string;
updates: { title: string; tag: string; body: string }[];
spotOfTheWeek?: {
title: string;
location: string;
why: string;
mapperName?: string;
image?: string;
href?: string;
};
communityNote: string;
}
/** Stable addressable identifier for a backfilled archive issue. */
export function issueSlug(number: number): string {
return `issue-${number}`;
}
/** Human title for an issue, e.g. "SkateSpots Drop #005". */
export function issueTitle(number: number): string {
return `SkateSpots Drop #${String(number).padStart(3, '0')}`;
}
/** Render a static issue's structured sections into a Markdown article body. */
export function issueToMarkdown(issue: StaticIssueLike): string {
const lines: string[] = [];
lines.push(`_${issue.vibe}_`);
lines.push('');
lines.push('## What shipped');
lines.push('');
for (const u of issue.updates) {
lines.push(`### ${u.title}`);
lines.push(`**${u.tag}**`);
lines.push('');
lines.push(u.body);
lines.push('');
}
if (issue.spotOfTheWeek) {
const s = issue.spotOfTheWeek;
lines.push('## Spot of the Week');
lines.push('');
lines.push(`**${s.title}** — ${s.location}`);
lines.push('');
lines.push(s.why);
if (s.mapperName) {
lines.push('');
lines.push(`_Mapped by ${s.mapperName}_`);
}
lines.push('');
}
lines.push('## Community');
lines.push('');
lines.push(issue.communityNote);
lines.push('');
lines.push('---');
lines.push('');
lines.push('Read every drop at https://skate-spots.org/#/newsletter');
return lines.join('\n');
}
/** A one-line summary for the article's NIP-23 `summary` tag. */
export function issueSummary(issue: StaticIssueLike): string {
return issue.vibe.length > 180 ? `${issue.vibe.slice(0, 177)}…` : issue.vibe;
}
/** Build the full (unsigned) kind-30023 event template for a static issue. */
export function buildIssueArticle(issue: StaticIssueLike) {
const publishedAt = Math.floor(new Date(issue.date + 'T12:00:00Z').getTime() / 1000);
const image = issue.spotOfTheWeek?.image;
return {
kind: NEWSLETTER_KIND,
content: issueToMarkdown(issue),
tags: [
['d', issueSlug(issue.number)],
['title', issueTitle(issue.number)],
['summary', issueSummary(issue)],
...(image ? [['image', image]] : []),
['published_at', String(Number.isFinite(publishedAt) ? publishedAt : Math.floor(Date.now() / 1000))],
['t', NEWSLETTER_TAG],
['t', 'skatespots'],
['t', 'newsletter'],
['t', 'skateboarding'],
['alt', `SkateSpots newsletter issue: ${issueTitle(issue.number)}`],
] as string[][],
};
}