@profullstack/builtindie 0.0.0-stage → 0.1.3
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/LICENSE +21 -0
- package/OpenStack.md +74 -0
- package/README.md +63 -2
- package/bin/builtindie +3 -0
- package/package.json +35 -4
- package/public/app.js +35 -0
- package/public/favicon.svg +1 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icons/openicon.json +574 -0
- package/public/icons/svg/apple.svg +1 -0
- package/public/icons/svg/arrow-up.svg +1 -0
- package/public/icons/svg/bluesky.svg +1 -0
- package/public/icons/svg/check.svg +1 -0
- package/public/icons/svg/chrome.svg +1 -0
- package/public/icons/svg/code.svg +1 -0
- package/public/icons/svg/discord.svg +1 -0
- package/public/icons/svg/dollar.svg +1 -0
- package/public/icons/svg/external-link.svg +1 -0
- package/public/icons/svg/github.svg +1 -0
- package/public/icons/svg/globe.svg +1 -0
- package/public/icons/svg/instagram.svg +1 -0
- package/public/icons/svg/layers.svg +1 -0
- package/public/icons/svg/link.svg +1 -0
- package/public/icons/svg/linkedin.svg +1 -0
- package/public/icons/svg/mail.svg +1 -0
- package/public/icons/svg/product-hunt.svg +1 -0
- package/public/icons/svg/reddit.svg +1 -0
- package/public/icons/svg/rocket.svg +1 -0
- package/public/icons/svg/search.svg +1 -0
- package/public/icons/svg/share.svg +1 -0
- package/public/icons/svg/star.svg +1 -0
- package/public/icons/svg/tag.svg +1 -0
- package/public/icons/svg/telegram.svg +1 -0
- package/public/icons/svg/terminal.svg +1 -0
- package/public/icons/svg/tiktok.svg +1 -0
- package/public/icons/svg/trophy.svg +1 -0
- package/public/icons/svg/user.svg +1 -0
- package/public/icons/svg/x.svg +1 -0
- package/public/icons/svg/youtube.svg +1 -0
- package/public/install.sh +44 -0
- package/public/manifest.webmanifest +20 -0
- package/public/moshpit-root-ca.crt +11 -0
- package/public/style.css +162 -0
- package/public/sw.js +30 -0
- package/src/auth.ts +96 -0
- package/src/cli.ts +203 -0
- package/src/client.ts +115 -0
- package/src/db.ts +412 -0
- package/src/fetch.ts +708 -0
- package/src/html.ts +525 -0
- package/src/icons.ts +56 -0
- package/src/mcp.ts +79 -0
- package/src/media.ts +160 -0
- package/src/ring.ts +86 -0
- package/src/server.ts +655 -0
- package/src/service.ts +369 -0
- package/src/tui.ts +127 -0
- package/src/version.ts +1 -0
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `builtindie mcp`: built.indie for an agent, on stdio.
|
|
3
|
+
*
|
|
4
|
+
* claude mcp add builtindie -- bunx @profullstack/builtindie mcp
|
|
5
|
+
*
|
|
6
|
+
* Reading, launching and voting need nothing. Claiming and editing need
|
|
7
|
+
* BUILTINDIE_KEY: an account API key from built.indie.pit.world/me.
|
|
8
|
+
*/
|
|
9
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
10
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
11
|
+
import { z } from 'zod'
|
|
12
|
+
import { BuiltIndieClient } from './client.js'
|
|
13
|
+
import { VERSION } from './version.js'
|
|
14
|
+
|
|
15
|
+
const text = (value: unknown) => ({ content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] })
|
|
16
|
+
const failure = (error: unknown) => ({ isError: true, content: [{ type: 'text', text: error instanceof Error ? error.message : String(error) }] })
|
|
17
|
+
const slug = z.string().min(1).max(60).describe('A product slug, e.g. linear (built.indie/p/linear)')
|
|
18
|
+
const wrap = <A,>(fn: (a: A) => Promise<unknown>) => async (a: A) => {
|
|
19
|
+
try {
|
|
20
|
+
return text(await fn(a))
|
|
21
|
+
} catch (e) {
|
|
22
|
+
return failure(e)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createMcpServer(client = new BuiltIndieClient()): McpServer {
|
|
27
|
+
const server: any = new McpServer({ name: 'built.indie', version: VERSION })
|
|
28
|
+
server.tool(
|
|
29
|
+
'launch_product',
|
|
30
|
+
'Launch a product on built.indie from its URL. We read the homepage (meta tags, Open Graph, JSON-LD, llms.txt, opensite.json, openprofile.md), save the logo and screenshots and put it on today\'s board. Free. To edit it later, the domain owner claims it (claim_product).',
|
|
31
|
+
{
|
|
32
|
+
url: z.string().min(3).describe('The product homepage, e.g. yourproduct.com'),
|
|
33
|
+
maker_name: z.string().max(60).optional(),
|
|
34
|
+
maker_profile: z.string().optional().describe("The maker's OpenProfile.md URL (logicsrc.com/openprofile)"),
|
|
35
|
+
tagline: z.string().max(90).optional().describe('Override the tagline we find'),
|
|
36
|
+
publish: z.boolean().optional().describe('Go live now (default true); false leaves a draft to check'),
|
|
37
|
+
},
|
|
38
|
+
wrap((a: { url: string; maker_name?: string; maker_profile?: string; tagline?: string; publish?: boolean }) => client.launch(a.url, { makerName: a.maker_name, makerProfile: a.maker_profile, tagline: a.tagline, publish: a.publish })),
|
|
39
|
+
)
|
|
40
|
+
server.tool('preview_site', 'Read any site the way built.indie does (name, tagline, description, logo, screenshots, videos, socials, llms.txt, OpenProfile) without saving anything.', { url: z.string().min(3) }, wrap((a: { url: string }) => client.preview(a.url)))
|
|
41
|
+
server.tool(
|
|
42
|
+
'list_products',
|
|
43
|
+
'Products launched on built.indie, best first: today, week, month, all, new (newest first) or featured.',
|
|
44
|
+
{ window: z.enum(['today', 'week', 'month', 'all', 'new', 'featured']).default('today'), limit: z.number().int().min(1).max(100).default(20) },
|
|
45
|
+
wrap((a: { window: string; limit: number }) => client.list(a.window, a.limit)),
|
|
46
|
+
)
|
|
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)))
|
|
49
|
+
server.tool('upvote', 'Upvote a product (one vote per visitor).', { slug }, wrap((a: { slug: string }) => client.vote(a.slug)))
|
|
50
|
+
server.tool(
|
|
51
|
+
'edit_product',
|
|
52
|
+
'Change a launch you claimed or submitted (needs BUILTINDIE_KEY). Any field left out stays as it is. refetch reads the homepage again; publish puts a draft live.',
|
|
53
|
+
{
|
|
54
|
+
slug,
|
|
55
|
+
name: z.string().max(60).optional(),
|
|
56
|
+
tagline: z.string().max(90).optional(),
|
|
57
|
+
description: z.string().max(1200).optional(),
|
|
58
|
+
tags: z.array(z.string()).max(6).optional(),
|
|
59
|
+
videos: z.array(z.string()).max(4).optional().describe('YouTube, Vimeo, Loom or .mp4 links'),
|
|
60
|
+
action: z.enum(['save', 'refetch', 'publish']).default('save'),
|
|
61
|
+
},
|
|
62
|
+
wrap(async (a: { slug: string; action: string; name?: string; tagline?: string; description?: string; tags?: string[]; videos?: string[] }) => {
|
|
63
|
+
if (a.action !== 'save') return client.act(a.slug, a.action as 'publish' | 'refetch')
|
|
64
|
+
return client.edit(a.slug, { name: a.name, tagline: a.tagline, description: a.description, tags: a.tags, videos: a.videos })
|
|
65
|
+
}),
|
|
66
|
+
)
|
|
67
|
+
server.tool(
|
|
68
|
+
'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.',
|
|
70
|
+
{ slug },
|
|
71
|
+
wrap((a: { slug: string }) => client.claim(a.slug)),
|
|
72
|
+
)
|
|
73
|
+
server.tool('my_products', 'Your launches and claims (needs BUILTINDIE_KEY).', {}, wrap(() => client.me()))
|
|
74
|
+
return server
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function runMcp(client = new BuiltIndieClient()): Promise<void> {
|
|
78
|
+
await createMcpServer(client).connect(new StdioServerTransport())
|
|
79
|
+
}
|
package/src/media.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pictures we keep: a product's logo, its card image, its screenshots and one
|
|
3
|
+
* screenshot of the homepage we take ourselves. Hot-linking breaks the day a
|
|
4
|
+
* maker redesigns, and our CSP allows images only from ourselves, so every
|
|
5
|
+
* picture is downloaded once, checked by its first bytes, and served from
|
|
6
|
+
* /m/<product>/<file>.
|
|
7
|
+
*
|
|
8
|
+
* The homepage screenshot is headless Chrome. When we run as root it runs as
|
|
9
|
+
* `nobody`, with its own throwaway profile, so a hostile page renders with no
|
|
10
|
+
* privileges; Chrome's Local Network Access keeps the page off our loopback.
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from 'node:child_process'
|
|
13
|
+
import { existsSync } from 'node:fs'
|
|
14
|
+
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
15
|
+
import { tmpdir } from 'node:os'
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
import { normalizeUrl, safeFetch, type FetchOptions } from './fetch.js'
|
|
18
|
+
|
|
19
|
+
export const IMAGE_TYPES: Record<string, string> = {
|
|
20
|
+
png: 'image/png',
|
|
21
|
+
jpg: 'image/jpeg',
|
|
22
|
+
gif: 'image/gif',
|
|
23
|
+
webp: 'image/webp',
|
|
24
|
+
avif: 'image/avif',
|
|
25
|
+
ico: 'image/x-icon',
|
|
26
|
+
svg: 'image/svg+xml',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The image kind by its first bytes, or null when it is not a picture we serve. */
|
|
30
|
+
export function sniff(b: Uint8Array): string | null {
|
|
31
|
+
const at = (i: number, ...v: number[]) => v.every((x, j) => b[i + j] === x)
|
|
32
|
+
if (at(0, 0x89, 0x50, 0x4e, 0x47)) return 'png'
|
|
33
|
+
if (at(0, 0xff, 0xd8, 0xff)) return 'jpg'
|
|
34
|
+
if (at(0, 0x47, 0x49, 0x46, 0x38)) return 'gif'
|
|
35
|
+
if (at(0, 0x52, 0x49, 0x46, 0x46) && at(8, 0x57, 0x45, 0x42, 0x50)) return 'webp'
|
|
36
|
+
if (at(4, 0x66, 0x74, 0x79, 0x70) && /^(avif|avis)/.test(String.fromCharCode(...b.slice(8, 12)))) return 'avif'
|
|
37
|
+
if (at(0, 0x00, 0x00, 0x01, 0x00)) return 'ico'
|
|
38
|
+
const head = new TextDecoder().decode(b.slice(0, 512)).trimStart().toLowerCase()
|
|
39
|
+
if (head.startsWith('<svg') || (head.startsWith('<?xml') && head.includes('<svg'))) return 'svg'
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Pixel size of a PNG, JPEG, GIF or WebP from its header; null when we cannot tell. */
|
|
44
|
+
export function dimensions(b: Uint8Array, kind: string): { width: number; height: number } | null {
|
|
45
|
+
const v = new DataView(b.buffer, b.byteOffset, b.byteLength)
|
|
46
|
+
try {
|
|
47
|
+
if (kind === 'png') return { width: v.getUint32(16), height: v.getUint32(20) }
|
|
48
|
+
if (kind === 'gif') return { width: v.getUint16(6, true), height: v.getUint16(8, true) }
|
|
49
|
+
if (kind === 'webp') {
|
|
50
|
+
const chunk = String.fromCharCode(...b.slice(12, 16))
|
|
51
|
+
if (chunk === 'VP8 ') return { width: v.getUint16(26, true) & 0x3fff, height: v.getUint16(28, true) & 0x3fff }
|
|
52
|
+
if (chunk === 'VP8L') {
|
|
53
|
+
const bits = v.getUint32(21, true)
|
|
54
|
+
return { width: (bits & 0x3fff) + 1, height: ((bits >> 14) & 0x3fff) + 1 }
|
|
55
|
+
}
|
|
56
|
+
if (chunk === 'VP8X') return { width: 1 + (b[24]! | (b[25]! << 8) | (b[26]! << 16)), height: 1 + (b[27]! | (b[28]! << 8) | (b[29]! << 16)) }
|
|
57
|
+
}
|
|
58
|
+
if (kind === 'jpg') {
|
|
59
|
+
let i = 2
|
|
60
|
+
while (i < b.length - 9) {
|
|
61
|
+
if (b[i] !== 0xff) return null
|
|
62
|
+
const marker = b[i + 1]!
|
|
63
|
+
const len = v.getUint16(i + 2)
|
|
64
|
+
if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) return { width: v.getUint16(i + 7), height: v.getUint16(i + 5) }
|
|
65
|
+
i += 2 + len
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} catch {}
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type Saved = { file: string; width: number | null; height: number | null }
|
|
73
|
+
|
|
74
|
+
export class Media {
|
|
75
|
+
constructor(readonly dir: string) {}
|
|
76
|
+
|
|
77
|
+
path(productId: string, file: string): string {
|
|
78
|
+
return join(this.dir, productId, file)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Download one picture into the product's folder. Null when it is missing, too big or not an image. */
|
|
82
|
+
async save(productId: string, name: string, url: string | null, o: FetchOptions & { minSide?: number } = {}): Promise<Saved | null> {
|
|
83
|
+
if (!url) return null
|
|
84
|
+
try {
|
|
85
|
+
const r = await safeFetch(url, { ...o, accept: 'image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.8', maxBytes: 6 * 1024 * 1024, timeoutMs: 10_000 })
|
|
86
|
+
if (r.status >= 300) return null
|
|
87
|
+
const kind = sniff(r.body)
|
|
88
|
+
if (!kind) return null
|
|
89
|
+
const size = dimensions(r.body, kind)
|
|
90
|
+
if (o.minSide && size && Math.min(size.width, size.height) < o.minSide) return null
|
|
91
|
+
return await this.write(productId, `${name}.${kind}`, r.body, size)
|
|
92
|
+
} catch {
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async write(productId: string, file: string, body: Uint8Array, size: { width: number; height: number } | null = null): Promise<Saved> {
|
|
98
|
+
await mkdir(join(this.dir, productId), { recursive: true })
|
|
99
|
+
await writeFile(this.path(productId, file), body)
|
|
100
|
+
return { file, width: size?.width ?? null, height: size?.height ?? null }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async read(productId: string, file: string): Promise<Uint8Array | null> {
|
|
104
|
+
if (!/^[A-Za-z0-9_-]{6,40}$/.test(productId) || !/^[a-z0-9-]{1,40}\.(png|jpg|gif|webp|avif|ico|svg)$/.test(file)) return null
|
|
105
|
+
try {
|
|
106
|
+
return new Uint8Array(await readFile(this.path(productId, file)))
|
|
107
|
+
} catch {
|
|
108
|
+
return null
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async remove(productId: string): Promise<void> {
|
|
113
|
+
if (/^[A-Za-z0-9_-]{6,40}$/.test(productId)) await rm(join(this.dir, productId), { recursive: true, force: true })
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const CHROMES = ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/snap/bin/chromium', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome']
|
|
118
|
+
|
|
119
|
+
export function findChrome(): string | null {
|
|
120
|
+
const own = process.env.BUILTINDIE_CHROME
|
|
121
|
+
if (own === 'off') return null
|
|
122
|
+
if (own) return own
|
|
123
|
+
return CHROMES.find((c) => existsSync(c)) ?? null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let queue: Promise<unknown> = Promise.resolve()
|
|
127
|
+
|
|
128
|
+
/** A 1280×800 PNG of the page, or null. One at a time: Chrome is the heaviest thing we run. */
|
|
129
|
+
export function screenshot(url: string, chrome = findChrome(), timeoutMs = 35_000): Promise<Uint8Array | null> {
|
|
130
|
+
if (!chrome) return Promise.resolve(null)
|
|
131
|
+
const target = normalizeUrl(url).href
|
|
132
|
+
const run = async (): Promise<Uint8Array | null> => {
|
|
133
|
+
const dir = await mkdtemp(join(tmpdir(), 'builtindie-shot-'))
|
|
134
|
+
try {
|
|
135
|
+
const root = process.getuid?.() === 0
|
|
136
|
+
if (root) await chmod(dir, 0o777)
|
|
137
|
+
const args = [
|
|
138
|
+
'--headless=new', '--disable-gpu', '--hide-scrollbars', '--mute-audio', '--no-first-run', '--no-default-browser-check',
|
|
139
|
+
'--disable-extensions', '--disable-sync', '--disable-background-networking', '--deny-permission-prompts',
|
|
140
|
+
`--user-data-dir=${join(dir, 'profile')}`, '--window-size=1280,800', '--virtual-time-budget=6000', `--screenshot=${join(dir, 'shot.png')}`, target,
|
|
141
|
+
]
|
|
142
|
+
// runuser lives in /usr/sbin, which a service's PATH often leaves out.
|
|
143
|
+
const runuser = ['/usr/sbin/runuser', '/sbin/runuser', '/usr/bin/runuser'].find((p) => existsSync(p)) ?? 'runuser'
|
|
144
|
+
const [cmd, argv] = root ? [runuser, ['-u', 'nobody', '--', '/usr/bin/env', `HOME=${dir}`, chrome, ...args]] : [chrome, args]
|
|
145
|
+
await new Promise<void>((resolve) => {
|
|
146
|
+
const child = spawn(cmd, argv as string[], { stdio: 'ignore' })
|
|
147
|
+
const timer = setTimeout(() => child.kill('SIGKILL'), timeoutMs)
|
|
148
|
+
child.on('exit', () => (clearTimeout(timer), resolve()))
|
|
149
|
+
child.on('error', () => (clearTimeout(timer), resolve()))
|
|
150
|
+
})
|
|
151
|
+
const png = new Uint8Array(await readFile(join(dir, 'shot.png')).catch(() => Buffer.alloc(0)))
|
|
152
|
+
return sniff(png) === 'png' ? png : null
|
|
153
|
+
} finally {
|
|
154
|
+
await rm(dir, { recursive: true, force: true }).catch(() => {})
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const next = queue.then(run, run)
|
|
158
|
+
queue = next.catch(() => null)
|
|
159
|
+
return next
|
|
160
|
+
}
|
package/src/ring.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Moshpit ring bar: the thin strip across the top of every page that links
|
|
3
|
+
* this site to the others on the ring (https://pit.world/ring).
|
|
4
|
+
*
|
|
5
|
+
* pit.world owns the member list. We read https://pit.world/api/ring every ten
|
|
6
|
+
* minutes in the background and draw the bar ourselves, so a page never waits
|
|
7
|
+
* on pit.world and needs no script or CSP exception; until the first read (or
|
|
8
|
+
* if pit.world is down) the list below is used.
|
|
9
|
+
*
|
|
10
|
+
* A Moshpit name opens only for a visitor on Moshpit DNS. Everyone else reaches
|
|
11
|
+
* it through pit.world's relay, https://<name>.pit.world/, which (like the
|
|
12
|
+
* registry gateway) tells us so with an x-moshpit-name header. So: a request for
|
|
13
|
+
* our own name without that header came from Moshpit DNS and gets direct links;
|
|
14
|
+
* anything else gets relay links.
|
|
15
|
+
*
|
|
16
|
+
* Same file in seo.rank, family.ancestry and pay.agent; pit.world has the original in src/ring.ts.
|
|
17
|
+
*/
|
|
18
|
+
const RING_HOME = 'https://pit.world/ring'
|
|
19
|
+
const RING_API = 'https://pit.world/api/ring'
|
|
20
|
+
const relayUrl = (name: string) => `https://${name}.pit.world/`
|
|
21
|
+
export const RING_PLACEHOLDER = '<!--ring-bar-->'
|
|
22
|
+
|
|
23
|
+
export type RingSite = { name: string; about: string; moshpit: boolean }
|
|
24
|
+
|
|
25
|
+
let sites: RingSite[] = [
|
|
26
|
+
{ name: 'pit.world', about: 'Private search, and the front door of the Moshpit network.', moshpit: false },
|
|
27
|
+
{ name: 'seo.rank', about: 'Free, open-source on-page SEO audit. API, CLI and MCP.', moshpit: true },
|
|
28
|
+
{ name: 'family.ancestry', about: 'Your family tree, on your own name. Import GEDCOM, share by link.', moshpit: true },
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
const ESC: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
|
32
|
+
const esc = (value: unknown) => String(value ?? '').replace(/[&<>"']/g, (c) => ESC[c]!)
|
|
33
|
+
|
|
34
|
+
export function ringSites(): RingSite[] {
|
|
35
|
+
return sites
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Replace the member list from an /api/ring document; a malformed one is ignored. */
|
|
39
|
+
export function setRing(doc: unknown): boolean {
|
|
40
|
+
const list = (doc as { sites?: unknown })?.sites
|
|
41
|
+
if (!Array.isArray(list)) return false
|
|
42
|
+
const next = list
|
|
43
|
+
.filter((s) => s && typeof s.name === 'string' && /^[a-z0-9.-]{1,253}$/.test(s.name))
|
|
44
|
+
.map((s) => ({ name: s.name as string, about: String(s.about ?? '').slice(0, 200), moshpit: s.moshpit === true }))
|
|
45
|
+
if (!next.length) return false
|
|
46
|
+
sites = next
|
|
47
|
+
return true
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Refresh from pit.world now and every `everyMs`. Failures keep the last good list. */
|
|
51
|
+
export function startRingRefresh(fetchImpl: typeof fetch = fetch, everyMs = 10 * 60_000): () => void {
|
|
52
|
+
const refresh = () =>
|
|
53
|
+
fetchImpl(RING_API, { signal: AbortSignal.timeout(5000) })
|
|
54
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
55
|
+
.then((doc) => doc && setRing(doc))
|
|
56
|
+
.catch(() => {})
|
|
57
|
+
refresh()
|
|
58
|
+
const timer = setInterval(refresh, everyMs)
|
|
59
|
+
timer.unref?.()
|
|
60
|
+
return () => clearInterval(timer)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** True when the request reached us by our own Moshpit name, not through the gateway. */
|
|
64
|
+
export function visitorOnMoshpit(self: string, host: string | undefined, gatewayHeader: string | undefined): boolean {
|
|
65
|
+
return !gatewayHeader && String(host ?? '').toLowerCase().replace(/:\d+$/, '') === self
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function siteHref(site: RingSite, direct: boolean): string {
|
|
69
|
+
return site.moshpit && !direct ? relayUrl(site.name) : `https://${site.name}/`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function ringBar(self: string, direct: boolean): string {
|
|
73
|
+
const at = sites.findIndex((s) => s.name === self)
|
|
74
|
+
const i = at < 0 ? 0 : at
|
|
75
|
+
const prev = sites[(i - 1 + sites.length) % sites.length]!
|
|
76
|
+
const next = sites[(i + 1) % sites.length]!
|
|
77
|
+
const link = (s: RingSite, text = s.name) =>
|
|
78
|
+
`<a href="${esc(siteHref(s, direct))}"${s.name === self ? ' aria-current="page"' : ''} title="${esc(s.about)}">${esc(text)}</a>`
|
|
79
|
+
return `<nav class="ring-bar" aria-label="Moshpit ring">
|
|
80
|
+
<a class="ring-home" href="${RING_HOME}">◉ <span>Moshpit </span>ring</a>
|
|
81
|
+
${link(prev, `‹ ${prev.name}`)}
|
|
82
|
+
<span class="ring-sites">${sites.map((s) => link(s)).join('')}</span>
|
|
83
|
+
${link(next, `${next.name} ›`)}
|
|
84
|
+
<a href="${RING_HOME}/random?from=${encodeURIComponent(self)}${direct ? '&direct=1' : ''}">random</a>
|
|
85
|
+
</nav>`
|
|
86
|
+
}
|