@profullstack/builtindie 0.1.4 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/karma.ts ADDED
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Karma: points for helping the board, one network-wide rule set (built.indie and
3
+ * pwamart.com pay the same actions the same points).
4
+ *
5
+ * Every write goes through here and lands in one append-only ledger
6
+ * (karma_events, db.ts). A dedupe key per fact means a retry or a double click
7
+ * never pays twice; a reversal (a comment deleted, a listing taken down) is a
8
+ * new negative row. Signed-in members earn; anonymous actions still work and
9
+ * earn nothing. Acting on your own listing earns nothing. Every
10
+ * KARMA_PER_CREDIT karma grants a free Featured week, once per milestone.
11
+ */
12
+ import { dayOf, type Product, type Store } from './db.js'
13
+
14
+ export const POINTS = {
15
+ launch: 10,
16
+ vote: 1,
17
+ review: 5,
18
+ comment: 2,
19
+ share: 1,
20
+ upvoted: 1,
21
+ reviewed: 2,
22
+ claim: 5,
23
+ invite: 25,
24
+ badge: 5,
25
+ } as const
26
+ export type KarmaAction = keyof typeof POINTS
27
+
28
+ /** Paid actions per member per UTC day; past it the action still works, it just pays nothing. */
29
+ export const DAILY_CAPS: Partial<Record<KarmaAction, number>> = { vote: 20, comment: 10, share: 5 }
30
+
31
+ /** The share intents we track: one paid click per listing per network per day. */
32
+ export const SHARE_NETWORKS = ['x', 'bluesky', 'linkedin', 'reddit', 'hn', 'mastodon', 'copy'] as const
33
+ export type ShareNetwork = (typeof SHARE_NETWORKS)[number]
34
+
35
+ export const MIN_REVIEW_CHARS = 30
36
+ export const MIN_COMMENT_CHARS = 10
37
+
38
+ /** What each ledger action reads as, in history lists. */
39
+ export const ACTION_LABELS: Record<string, string> = {
40
+ launch: 'Launched a listing',
41
+ vote: 'Upvoted a launch',
42
+ review: 'Reviewed a launch',
43
+ comment: 'Commented',
44
+ share: 'Shared a launch',
45
+ upvoted: 'Your launch got an upvote',
46
+ reviewed: 'Your launch got a review',
47
+ claim: 'Claimed a listing',
48
+ invite: 'An invited maker launched',
49
+ badge: 'Badge verified on your site',
50
+ comment_deleted: 'Comment deleted',
51
+ review_deleted: 'Review deleted',
52
+ reviewed_deleted: 'A review of your launch was deleted',
53
+ taken_down: 'Listing taken down',
54
+ }
55
+
56
+ /** The "how to earn" table, for the member page, the leaderboard and the docs. */
57
+ export const HOW_TO_EARN: { action: string; points: number; rule: string }[] = [
58
+ { action: 'Launch a listing', points: POINTS.launch, rule: 'the first time each listing goes live' },
59
+ { action: 'Upvote', points: POINTS.vote, rule: `not your own listing; up to ${DAILY_CAPS.vote} paid a day` },
60
+ { action: 'Review', points: POINTS.review, rule: `stars and at least ${MIN_REVIEW_CHARS} characters; once per listing; not your own` },
61
+ { action: 'Comment', points: POINTS.comment, rule: `at least ${MIN_COMMENT_CHARS} characters; up to ${DAILY_CAPS.comment} paid a day; deleting it takes the points back` },
62
+ { action: 'Share', points: POINTS.share, rule: `a share button (X, Bluesky, LinkedIn, Reddit, HN, Mastodon, copy link); once per listing per network per day; up to ${DAILY_CAPS.share} a day` },
63
+ { action: 'Your launch gets an upvote', points: POINTS.upvoted, rule: 'from a signed-in member who is not you' },
64
+ { action: 'Your launch gets a review', points: POINTS.reviewed, rule: 'from someone else' },
65
+ { action: 'Claim your listing', points: POINTS.claim, rule: 'prove you own the domain (badge or DNS), once per listing' },
66
+ { action: 'Invite a maker who launches', points: POINTS.invite, rule: 'a new member from your invite link launches their first listing' },
67
+ { action: 'Badge verified', points: POINTS.badge, rule: 'our badge is on your homepage and we found it, once per listing' },
68
+ ]
69
+
70
+ /** Who a listing's karma goes to: the owner who claimed it, else the member who launched it. */
71
+ export const ownerOf = (p: Pick<Product, 'ownerId' | 'submitterUser'>) => p.ownerId ?? p.submitterUser ?? null
72
+ const isSelf = (p: Pick<Product, 'ownerId' | 'submitterUser'>, userId: string) => p.ownerId === userId || p.submitterUser === userId
73
+
74
+ export type KarmaSummary = { total: number; week: number; perCredit: number; nextCreditIn: number; progress: number; credits: { granted: number; used: number }; events: { action: string; label: string; ref: string; points: number; at: number }[] }
75
+
76
+ export class Karma {
77
+ /** Called after every write that changed someone's karma: the server refreshes the board and awards badges. */
78
+ onChange?: (userId: string) => void
79
+ /** Called when a milestone grants a free Featured week. */
80
+ onCredit?: (userId: string, granted: number) => void
81
+
82
+ constructor(readonly store: Store, readonly perCredit = 500) {}
83
+
84
+ private pay(userId: string | null, action: KarmaAction, ref: string, dedupe: string, opts: { daily?: boolean; capped?: boolean } = {}, now = Date.now()): boolean {
85
+ if (!userId) return false
86
+ const ok = this.store.addKarma({ userId, action, ref, points: POINTS[action], dedupe, daily: opts.daily, cap: opts.capped ? DAILY_CAPS[action] : undefined }, now)
87
+ if (ok) this.changed(userId, now)
88
+ return ok
89
+ }
90
+
91
+ private changed(userId: string, now = Date.now()): void {
92
+ const granted = this.store.grantKarmaCredits(userId, this.perCredit, now)
93
+ if (granted) this.onCredit?.(userId, granted)
94
+ this.onChange?.(userId)
95
+ }
96
+
97
+ private reverse(dedupe: string, action: string): boolean {
98
+ const userId = this.store.reverseKarma(dedupe, action)
99
+ if (userId) this.onChange?.(userId)
100
+ return Boolean(userId)
101
+ }
102
+
103
+ /** A listing went live for the first time. Not for anonymous or admin-bearer imports (no member). */
104
+ launched(p: Product, userId: string | null = p.submitterUser): boolean {
105
+ return this.pay(userId, 'launch', p.id, `launch:${p.id}`)
106
+ }
107
+
108
+ /** A counted vote. The voter earns (capped), the owner earns for a vote from someone else; both only when the voter is a member and not the owner. */
109
+ voted(p: Product, voterId: string | null, now = Date.now()): { voter: boolean; owner: boolean } {
110
+ if (!voterId || isSelf(p, voterId)) return { voter: false, owner: false }
111
+ const voter = this.pay(voterId, 'vote', p.id, `vote:${voterId}:${p.id}`, { capped: true }, now)
112
+ const owner = this.pay(ownerOf(p), 'upvoted', `${p.id}:${voterId}`, `upvoted:${p.id}:${voterId}`, {}, now)
113
+ return { voter, owner }
114
+ }
115
+
116
+ /** A review saved (new or edited). Pays once per listing, when it has stars and enough words, and never on your own. */
117
+ reviewed(p: Product, userId: string, review: { rating: number; body: string }): { reviewer: boolean; owner: boolean } {
118
+ if (isSelf(p, userId) || !(review.rating >= 1 && review.rating <= 5) || review.body.trim().length < MIN_REVIEW_CHARS) return { reviewer: false, owner: false }
119
+ const reviewer = this.pay(userId, 'review', p.id, `review:${userId}:${p.id}`)
120
+ const owner = this.pay(ownerOf(p), 'reviewed', `${p.id}:${userId}`, `reviewed:${p.id}:${userId}`)
121
+ return { reviewer, owner }
122
+ }
123
+
124
+ /** A review deleted: both sides give back what it paid. Writing a new one later pays nothing again. */
125
+ unreviewed(p: Product, userId: string): void {
126
+ this.reverse(`review:${userId}:${p.id}`, 'review_deleted')
127
+ this.reverse(`reviewed:${p.id}:${userId}`, 'reviewed_deleted')
128
+ }
129
+
130
+ commented(p: Product, userId: string, comment: { id: string; body: string }, now = Date.now()): boolean {
131
+ if (isSelf(p, userId) || comment.body.trim().length < MIN_COMMENT_CHARS) return false
132
+ return this.pay(userId, 'comment', `${p.id}:${comment.id}`, `comment:${comment.id}`, { capped: true }, now)
133
+ }
134
+
135
+ /** A comment deleted (by its author or an admin): the points it earned go back. */
136
+ uncommented(commentId: string): boolean {
137
+ return this.reverse(`comment:${commentId}`, 'comment_deleted')
138
+ }
139
+
140
+ shared(p: Product, userId: string | null, network: string, now = Date.now()): boolean {
141
+ if (!userId || isSelf(p, userId) || !(SHARE_NETWORKS as readonly string[]).includes(network)) return false
142
+ return this.pay(userId, 'share', `${p.id}:${network}`, `share:${userId}:${p.id}:${network}:${dayOf(now)}`, { capped: true }, now)
143
+ }
144
+
145
+ claimed(p: Product, userId: string): boolean {
146
+ return this.pay(userId, 'claim', p.id, `claim:${p.id}`)
147
+ }
148
+
149
+ badgeVerified(p: Product): boolean {
150
+ return this.pay(ownerOf(p), 'badge', p.id, `badge:${p.id}`)
151
+ }
152
+
153
+ inviteConverted(inviterId: string, productId: string): boolean {
154
+ return this.pay(inviterId, 'invite', productId, `invite:${productId}`)
155
+ }
156
+
157
+ /** A listing taken down for spam: everything it paid anyone goes back. Credits already granted stay granted. */
158
+ takenDown(productId: string): number {
159
+ let n = 0
160
+ for (const key of this.store.karmaAbout(productId)) n += Number(this.reverse(key, 'taken_down'))
161
+ return n
162
+ }
163
+
164
+ summary(userId: string, now = Date.now()): KarmaSummary {
165
+ const total = this.store.karmaTotal(userId)
166
+ const credits = this.store.karmaCredits(userId)
167
+ // The next milestone is past both the karma held and the last one granted (a reversal never re-pays a milestone).
168
+ const next = Math.max(Math.floor(Math.max(total, 0) / this.perCredit) + 1, credits.best / this.perCredit + 1) * this.perCredit
169
+ return {
170
+ total,
171
+ week: this.store.karmaTotal(userId, now - 7 * 86400_000),
172
+ perCredit: this.perCredit,
173
+ nextCreditIn: next - total,
174
+ progress: Math.max(0, Math.min(1, 1 - (next - total) / this.perCredit)),
175
+ credits: { granted: credits.granted, used: credits.used },
176
+ events: this.store.karmaEvents(userId).map((e) => ({ action: e.action, label: ACTION_LABELS[e.action] ?? e.action, ref: e.ref, points: e.points, at: e.createdAt })),
177
+ }
178
+ }
179
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The leaderboards, on @profullstack/leaderboard: karma (the default board) and
3
+ * inviters. Both project tables we already keep, the karma ledger and the
4
+ * invites table (an invite counts once the member it brought in launches), so
5
+ * there is no second copy of the facts to drift; badges, which are awarded
6
+ * rather than derived, live in the same SQLite file (lb_ tables).
7
+ *
8
+ * A projection is never record()ed: call award(player) after every karma or
9
+ * invite write, or badges never appear.
10
+ *
11
+ * The package serves /leaderboard.json, /leaderboard/<board>.json|.xml, the
12
+ * share card /leaderboard/u/<id> and the widget /leaderboard/embed.js; the
13
+ * /leaderboard page itself is ours, in the site's layout (server.ts).
14
+ */
15
+ import { createLeaderboard, projectionStore, sqlStore, type Period, type Profile } from '@profullstack/leaderboard'
16
+ import type { Store } from './db.js'
17
+
18
+ const count = (p: Profile) => Number(p.totals?.invites ?? 0)
19
+ const karma = (p: Profile) => Number(p.totals?.karma ?? 0)
20
+ const bestRank = (p: Profile, period: Period) => Math.min(...Object.values(p.ranks ?? {}).map((r) => r?.[period] ?? Infinity))
21
+
22
+ export function siteLeaderboard(store: Store, origin: string) {
23
+ const badges = sqlStore({ execute: ({ sql, args }) => ({ rows: store.db.query(sql).all(...((args ?? []) as never[])) as Record<string, unknown>[] }) })
24
+ for (const sql of badges.schema) store.db.exec(sql)
25
+ return createLeaderboard({
26
+ siteName: 'built.indie',
27
+ siteUrl: origin,
28
+ periods: ['week', 'month', 'all'],
29
+ defaultPeriod: 'week',
30
+ sides: { use: 'Members' },
31
+ boards: {
32
+ karma: { label: 'Top karma', metric: 'karma', format: 'integer', unit: 'Karma', side: 'use', actor: 'Member', min: 1 },
33
+ invites: { label: 'Top inviters', metric: 'invites', format: 'integer', unit: 'Makers launched', side: 'use', actor: 'Member', min: 1 },
34
+ },
35
+ badges: [
36
+ { id: 'first-karma', emoji: '✨', label: 'First karma', describe: 'Earned 10 karma', when: (p) => karma(p) >= 10 },
37
+ { id: 'karma-100', emoji: '💯', label: '100 karma', describe: 'Earned 100 karma', when: (p) => karma(p) >= 100 },
38
+ { id: 'karma-1000', emoji: '🔥', label: '1,000 karma', describe: 'Earned 1,000 karma', when: (p) => karma(p) >= 1000 },
39
+ { id: 'first-invite', emoji: '🎟️', label: 'First invite', describe: 'Brought in a maker who launched', when: (p) => count(p) >= 1 },
40
+ { id: 'five-invites', emoji: '🚀', label: 'Five makers', describe: 'Five invited makers launched', when: (p) => count(p) >= 5 },
41
+ { id: 'twenty-five-invites', emoji: '🌟', label: 'Twenty-five makers', describe: 'Twenty-five invited makers launched', when: (p) => count(p) >= 25 },
42
+ { id: 'top-ten-week', emoji: '🏅', label: 'Top ten this week', describe: 'In a weekly top ten (karma or inviters)', when: (p) => bestRank(p, 'week') <= 10 },
43
+ { id: 'number-one', emoji: '👑', label: 'Number one', describe: 'Number one for a week, a month or all time', when: (p) => [bestRank(p, 'week'), bestRank(p, 'month'), bestRank(p, 'all')].includes(1) },
44
+ ],
45
+ store: projectionStore({
46
+ events: async ({ since }: { since: number }) => {
47
+ const names = new Map<string, string>()
48
+ const name = (id: string) => names.get(id) ?? (names.set(id, store.publicName(id)), names.get(id)!)
49
+ return [
50
+ ...store.karmaSince(since).map((e) => ({ player: e.userId, name: name(e.userId), metric: 'karma', delta: e.points, at: e.at })),
51
+ ...store.earnedInvites(since).map((e) => ({ player: e.inviter, name: name(e.inviter), metric: 'invites', delta: 1, at: e.at })),
52
+ ]
53
+ },
54
+ badges,
55
+ }),
56
+ })
57
+ }
58
+
59
+ export type SiteBoard = ReturnType<typeof siteLeaderboard>
package/src/mcp.ts CHANGED
@@ -3,13 +3,13 @@
3
3
  *
4
4
  * claude mcp add builtindie -- bunx @profullstack/builtindie mcp
5
5
  *
6
- * Reading, launching and voting need nothing. Claiming and editing need
6
+ * Reading, launching and voting need nothing. Claiming, editing, commenting, reviewing and karma need
7
7
  * BUILTINDIE_KEY: an account API key from built.indie.pit.world/me.
8
8
  */
9
9
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
10
10
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
11
11
  import { z } from 'zod'
12
- import { BuiltIndieClient } from './client.js'
12
+ import { BuiltIndieClient, type SearchFilters } from './client.js'
13
13
  import { VERSION } from './version.js'
14
14
 
15
15
  const text = (value: unknown) => ({ content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] })
@@ -45,7 +45,31 @@ export function createMcpServer(client = new BuiltIndieClient()): McpServer {
45
45
  wrap((a: { window: string; limit: number }) => client.list(a.window, a.limit)),
46
46
  )
47
47
  server.tool('get_product', 'Everything about one launch: description, pictures, videos, socials, maker, votes, award.', { slug }, wrap((a: { slug: string }) => client.product(a.slug)))
48
- server.tool('search', 'Search launches by name, tagline, domain or tag.', { query: z.string().min(1).max(60) }, wrap((a: { query: string }) => client.search(a.query)))
48
+ server.tool('list_tags', 'Every tag on built.indie with how many launches carry it, most used first.', {}, wrap(() => client.tags()))
49
+ server.tool(
50
+ 'tagged',
51
+ 'Launches with one exact tag (from list_tags), best first.',
52
+ { tag: z.string().min(2).max(24), limit: z.number().int().min(1).max(100).default(20) },
53
+ wrap((a: { tag: string; limit: number }) => client.tagged(a.tag, a.limit)),
54
+ )
55
+ server.tool(
56
+ 'search',
57
+ 'Search and filter launches. Words match name, tags, category, domain, tagline, maker, stack and description. Narrow with categories (see list_categories), tags, has (verified, claimed, featured, video, screenshots, llms, opensite, openprofile, openstack, opensaas, maker), stack, maker and launched. The answer carries facets: counts for each value, to narrow further.',
58
+ {
59
+ query: z.string().max(80).default(''),
60
+ category: z.array(z.string()).max(10).optional().describe('Category keys, e.g. developer-tools, ai, crypto, security (any of them)'),
61
+ tag: z.array(z.string()).max(10).optional().describe('Tags (any of them)'),
62
+ has: z.array(z.string()).max(10).optional().describe('Every one must hold: verified, claimed, featured, video, screenshots, llms, opensite, openprofile, openstack, opensaas, maker'),
63
+ stack: z.array(z.string()).max(10).optional().describe('Built with (from the product\'s OpenStack.md)'),
64
+ maker: z.array(z.string()).max(10).optional(),
65
+ launched: z.enum(['today', 'week', 'month', 'year']).optional(),
66
+ sort: z.enum(['relevance', 'top', 'new', 'name']).optional(),
67
+ page: z.number().int().min(1).max(500).optional(),
68
+ limit: z.number().int().min(1).max(100).default(20),
69
+ },
70
+ wrap(({ query, ...filters }: { query: string } & SearchFilters) => client.search(query, filters)),
71
+ )
72
+ server.tool('list_categories', 'Every category and tag on built.indie, with how many launches each has.', {}, wrap(() => client.categories()))
49
73
  server.tool('upvote', 'Upvote a product (one vote per visitor).', { slug }, wrap((a: { slug: string }) => client.vote(a.slug)))
50
74
  server.tool(
51
75
  'edit_product',
@@ -66,11 +90,38 @@ export function createMcpServer(client = new BuiltIndieClient()): McpServer {
66
90
  )
67
91
  server.tool(
68
92
  'claim_product',
69
- 'Claim a listing for its domain owner (needs BUILTINDIE_KEY). Returns the DNS TXT record to add, plus `markdown`: step-by-step instructions for whoever has DNS access. Call again once it is in DNS and claimed comes back true.',
93
+ 'Claim a listing for its domain owner (needs BUILTINDIE_KEY). Two proofs, either works. Easiest: put the `badge` HTML on the product homepage; it is <img src="https://built.indie.pit.world/images.svg?id=<id>"> with an id unique to this listing and this account, and it also earns the followed link and the ✓. Or add the DNS TXT `record`. Also returns `markdown`: step-by-step instructions for whoever has site or DNS access. Call again once either is live; claimed comes back true with `method` badge or dns.',
70
94
  { slug },
71
95
  wrap((a: { slug: string }) => client.claim(a.slug)),
72
96
  )
73
97
  server.tool('my_products', 'Your launches and claims (needs BUILTINDIE_KEY).', {}, wrap(() => client.me()))
98
+ server.tool('my_invites', 'Your invite link and credits (needs BUILTINDIE_KEY). Each new member who signs up through the link and launches a listing earns one credit: a free Featured week.', {}, wrap(() => client.invites()))
99
+ server.tool('feature_with_credit', 'Spend one invite credit to feature a listing you own for a week (needs BUILTINDIE_KEY).', { slug }, wrap((a: { slug: string }) => client.credit(a.slug)))
100
+ server.tool('redeem_code', 'Redeem a credit code (CR-XXXX-XXXX-XXXX-XXXX) for Featured days on your balance (needs BUILTINDIE_KEY).', { code: z.string().min(4).describe('The credit code') }, wrap((a: { code: string }) => client.redeem(a.code)))
101
+ server.tool('create_invite', 'Make one of your 3 single-use invite codes (needs BUILTINDIE_KEY). Someone new who signs up through the link gives you both Featured days. The link is shown once.', { note: z.string().max(80).optional().describe('Who it is for, only you see it') }, wrap((a: { note?: string }) => client.inviteCode(a.note ?? '')))
102
+ server.tool('feature_with_days', 'Spend Featured days from your balance (from codes and invites) on a listing you own (needs BUILTINDIE_KEY).', { slug, days: z.number().int().min(1).describe('How many days') }, wrap((a: { slug: string; days: number }) => client.featureDays(a.slug, a.days)))
103
+ server.tool('feature_paid', 'Pay to feature a listing you own (needs BUILTINDIE_KEY): returns a CoinPay hosted checkout_url where the buyer picks any coin or PayPal. The receipt goes to email (default: your account email).', { slug, email: z.string().email().optional().describe('Where the receipt goes') }, wrap((a: { slug: string; email?: string }) => client.feature(a.slug, a.email)))
104
+ server.tool('my_karma', 'Your karma on built.indie (needs BUILTINDIE_KEY): total, this week, recent history, how many karma to the next free Featured week, and the how-to-earn table.', {}, wrap(() => client.karma()))
105
+ server.tool(
106
+ 'leaderboard',
107
+ 'The built.indie leaderboard: top karma (default) or top inviters, for a week, a month or all time.',
108
+ { board: z.enum(['karma', 'invites']).default('karma'), period: z.enum(['week', 'month', 'all']).default('week'), limit: z.number().int().min(1).max(100).default(10) },
109
+ wrap((a: { board: string; period: string; limit: number }) => client.leaderboard(a.board, a.period, a.limit)),
110
+ )
111
+ server.tool('list_comments', 'The comments on a launch, oldest first, with each author\'s karma; replies carry their parent id.', { slug }, wrap((a: { slug: string }) => client.comments(a.slug)))
112
+ server.tool(
113
+ 'comment',
114
+ 'Comment on a launch (needs BUILTINDIE_KEY). 10+ characters earns 2 karma (10 paid a day; not on your own listing). parent replies to a comment.',
115
+ { slug, body: z.string().min(2).max(2000), parent: z.string().optional().describe('A comment id to reply to') },
116
+ wrap((a: { slug: string; body: string; parent?: string }) => client.comment(a.slug, a.body, a.parent)),
117
+ )
118
+ server.tool('list_reviews', 'The reviews of a launch with its average rating.', { slug }, wrap((a: { slug: string }) => client.reviews(a.slug)))
119
+ server.tool(
120
+ 'review',
121
+ 'Review a launch (needs BUILTINDIE_KEY): 1-5 stars and text; one per member per launch, posting again updates it. 30+ characters earns 5 karma, once; not on your own listing.',
122
+ { slug, rating: z.number().int().min(1).max(5), body: z.string().max(2000).default('') },
123
+ wrap((a: { slug: string; rating: number; body: string }) => client.review(a.slug, a.rating, a.body)),
124
+ )
74
125
  return server
75
126
  }
76
127
 
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The pit.world global footer: one line (site · Search · Ring · Publish here)
3
+ * that pit.world serves as an HTML fragment for every site on the network.
4
+ *
5
+ * We read https://pit.world/snippets/footer.html?site=<name> in the background
6
+ * (3 s timeout, again every hour) and print the last good copy as the last thing
7
+ * in <body>, so a page never waits on pit.world. Until the first read, or if
8
+ * pit.world is down, it renders nothing. The fragment carries data-pit-footer,
9
+ * which tells the pit.world relay not to add a second copy.
10
+ */
11
+ const SNIPPET = 'https://pit.world/snippets/footer.html'
12
+
13
+ let cached = ''
14
+
15
+ /** The last good footer fragment, or '' before the first read. */
16
+ export function pitFooter(): string {
17
+ return cached
18
+ }
19
+
20
+ /** Accept a fragment only if it looks like the footer; anything else is ignored. */
21
+ export function setPitFooter(html: unknown): boolean {
22
+ if (typeof html !== 'string' || !html.includes('data-pit-footer') || html.length > 64_000) return false
23
+ cached = html.trim()
24
+ return true
25
+ }
26
+
27
+ /** Refresh from pit.world now and every `everyMs`. Failures keep the last good copy. */
28
+ export function startPitFooterRefresh(site: string, fetchImpl: typeof fetch = fetch, everyMs = 60 * 60_000): () => void {
29
+ const url = `${SNIPPET}?site=${encodeURIComponent(site)}`
30
+ const refresh = () =>
31
+ fetchImpl(url, { signal: AbortSignal.timeout(3000) })
32
+ .then((r) => (r.ok ? r.text() : null))
33
+ .then((html) => html && setPitFooter(html))
34
+ .catch(() => {})
35
+ refresh()
36
+ const timer = setInterval(refresh, everyMs)
37
+ timer.unref?.()
38
+ return () => clearInterval(timer)
39
+ }
package/src/profile.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The shared Profullstack profile (@profullstack/profile): email, first and last
3
+ * name, company domain, job title and LinkedIn URL, in the package's own
4
+ * `user_profiles` table keyed by users.id. Private to the member: nothing here is
5
+ * shown on a public page.
6
+ */
7
+ import type { Database } from 'bun:sqlite'
8
+ import { createProfileStore, migrate, splitFullName, type ProfileStore } from '@profullstack/profile'
9
+
10
+ /** The store, and `ready` once its table exists (created when the database opens). */
11
+ export function openProfiles(db: Database): { store: ProfileStore; ready: Promise<ProfileStore> } {
12
+ const store = createProfileStore(db, { keyType: 'text' })
13
+ const ready = migrate(store).then(async () => {
14
+ await backfill(db, store)
15
+ return store
16
+ })
17
+ ready.catch((error) => console.error(`[profile] migration failed: ${(error as Error).message}`))
18
+ return { store, ready }
19
+ }
20
+
21
+ /**
22
+ * One-time and idempotent: a display name typed as "First Last" becomes the
23
+ * profile's names. A single word is a handle, not a name, and is left alone, and
24
+ * fillBlanks never overwrites what a member saved.
25
+ */
26
+ async function backfill(db: Database, store: ProfileStore) {
27
+ const rows = db.query(`SELECT u.id, u.email, u.display_name FROM users u
28
+ WHERE instr(trim(u.display_name), ' ') > 0 AND NOT EXISTS (SELECT 1 FROM user_profiles p WHERE p.user_id = u.id)`)
29
+ .all() as { id: string; email: string; display_name: string }[]
30
+ for (const row of rows) {
31
+ const email = row.email.endsWith('.invalid') ? {} : { email: row.email }
32
+ try { await store.fillBlanks(row.id, { ...splitFullName(row.display_name), ...email }) } catch { /* a name the validator refuses is skipped */ }
33
+ }
34
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * built.indie's footer: its links, the << Profullstack >> webring nav and the
3
+ * copyright line, from @profullstack/footer. It is rendered into the HTML on the
4
+ * server because rssamplifier's ring verifier reads the page and never runs script.
5
+ *
6
+ * Pages render synchronously, so the footer is kept as a string: the bundled
7
+ * template at boot, then the @latest template from the CDN, refreshed every 15
8
+ * minutes (a footer release reaches us with no redeploy). A failed refresh keeps
9
+ * the last good copy. Our CSP allows inline styles, so the template CSS stays on.
10
+ */
11
+ import { footerHtml, footerHtmlSync } from '@profullstack/footer'
12
+
13
+ type FooterOptions = Parameters<typeof footerHtmlSync>[0]
14
+
15
+ export function ringFooterOptions(links: { label: string; href: string }[]): FooterOptions {
16
+ return {
17
+ site: 'https://built.indie.pit.world/',
18
+ links,
19
+ since: 2026,
20
+ tagline: 'Free, open source (MIT)',
21
+ }
22
+ }
23
+
24
+ let options: FooterOptions | null = null
25
+ let html = ''
26
+
27
+ export function setRingFooterOptions(next: FooterOptions): void {
28
+ options = next
29
+ html = footerHtmlSync(next)
30
+ }
31
+
32
+ /** The footer markup, never empty once options are set. */
33
+ export function ringFooter(): string {
34
+ return html
35
+ }
36
+
37
+ /** Accept a rendered footer only when it still links the ring. */
38
+ export function setRingFooter(next: unknown): boolean {
39
+ if (typeof next !== 'string' || !next.includes('/ring/profullstack') || next.length > 64_000) return false
40
+ html = next
41
+ return true
42
+ }
43
+
44
+ export function startRingFooterRefresh(everyMs = 15 * 60_000): () => void {
45
+ const run = () => {
46
+ if (!options) return
47
+ footerHtml(options).then(setRingFooter, () => {})
48
+ }
49
+ run()
50
+ const timer = setInterval(run, everyMs)
51
+ ;(timer as { unref?: () => void }).unref?.()
52
+ return () => clearInterval(timer)
53
+ }
package/src/ring.ts CHANGED
@@ -69,6 +69,7 @@ export function siteHref(site: RingSite, direct: boolean): string {
69
69
  return site.moshpit && !direct ? relayUrl(site.name) : `https://${site.name}/`
70
70
  }
71
71
 
72
+ /** The ring, the site you are on between its neighbours, random, and the way in for a builder (pit.world/start). */
72
73
  export function ringBar(self: string, direct: boolean): string {
73
74
  const at = sites.findIndex((s) => s.name === self)
74
75
  const i = at < 0 ? 0 : at
@@ -76,11 +77,14 @@ export function ringBar(self: string, direct: boolean): string {
76
77
  const next = sites[(i + 1) % sites.length]!
77
78
  const link = (s: RingSite, text = s.name) =>
78
79
  `<a href="${esc(siteHref(s, direct))}"${s.name === self ? ' aria-current="page"' : ''} title="${esc(s.about)}">${esc(text)}</a>`
80
+ const current = sites.find((s) => s.name === self)
81
+ const here = current ? link(current) : `<span aria-current="page">${esc(self)}</span>`
79
82
  return `<nav class="ring-bar" aria-label="Moshpit ring">
80
83
  <a class="ring-home" href="${RING_HOME}">◉ <span>Moshpit </span>ring</a>
81
84
  ${link(prev, `‹ ${prev.name}`)}
82
- <span class="ring-sites">${sites.map((s) => link(s)).join('')}</span>
85
+ <span class="ring-sites">${here}</span>
83
86
  ${link(next, `${next.name} ›`)}
84
87
  <a href="${RING_HOME}/random?from=${encodeURIComponent(self)}${direct ? '&amp;direct=1' : ''}">random</a>
88
+ <a class="ring-start" href="https://pit.world/start">Start building here…</a>
85
89
  </nav>`
86
90
  }