@profullstack/builtindie 0.0.0-stage → 0.1.2

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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/OpenStack.md +74 -0
  3. package/README.md +63 -2
  4. package/bin/builtindie +3 -0
  5. package/package.json +35 -4
  6. package/public/app.js +35 -0
  7. package/public/favicon.svg +1 -0
  8. package/public/icon-192.png +0 -0
  9. package/public/icon-512.png +0 -0
  10. package/public/icons/openicon.json +574 -0
  11. package/public/icons/svg/apple.svg +1 -0
  12. package/public/icons/svg/arrow-up.svg +1 -0
  13. package/public/icons/svg/bluesky.svg +1 -0
  14. package/public/icons/svg/check.svg +1 -0
  15. package/public/icons/svg/chrome.svg +1 -0
  16. package/public/icons/svg/code.svg +1 -0
  17. package/public/icons/svg/discord.svg +1 -0
  18. package/public/icons/svg/dollar.svg +1 -0
  19. package/public/icons/svg/external-link.svg +1 -0
  20. package/public/icons/svg/github.svg +1 -0
  21. package/public/icons/svg/globe.svg +1 -0
  22. package/public/icons/svg/instagram.svg +1 -0
  23. package/public/icons/svg/layers.svg +1 -0
  24. package/public/icons/svg/link.svg +1 -0
  25. package/public/icons/svg/linkedin.svg +1 -0
  26. package/public/icons/svg/mail.svg +1 -0
  27. package/public/icons/svg/product-hunt.svg +1 -0
  28. package/public/icons/svg/reddit.svg +1 -0
  29. package/public/icons/svg/rocket.svg +1 -0
  30. package/public/icons/svg/search.svg +1 -0
  31. package/public/icons/svg/share.svg +1 -0
  32. package/public/icons/svg/star.svg +1 -0
  33. package/public/icons/svg/tag.svg +1 -0
  34. package/public/icons/svg/telegram.svg +1 -0
  35. package/public/icons/svg/terminal.svg +1 -0
  36. package/public/icons/svg/tiktok.svg +1 -0
  37. package/public/icons/svg/trophy.svg +1 -0
  38. package/public/icons/svg/user.svg +1 -0
  39. package/public/icons/svg/x.svg +1 -0
  40. package/public/icons/svg/youtube.svg +1 -0
  41. package/public/install.sh +44 -0
  42. package/public/manifest.webmanifest +20 -0
  43. package/public/moshpit-root-ca.crt +11 -0
  44. package/public/style.css +162 -0
  45. package/public/sw.js +30 -0
  46. package/src/auth.ts +96 -0
  47. package/src/cli.ts +197 -0
  48. package/src/client.ts +115 -0
  49. package/src/db.ts +412 -0
  50. package/src/fetch.ts +708 -0
  51. package/src/html.ts +475 -0
  52. package/src/icons.ts +56 -0
  53. package/src/mcp.ts +79 -0
  54. package/src/media.ts +160 -0
  55. package/src/ring.ts +86 -0
  56. package/src/server.ts +647 -0
  57. package/src/service.ts +369 -0
  58. package/src/tui.ts +127 -0
  59. package/src/version.ts +1 -0
package/src/html.ts ADDED
@@ -0,0 +1,475 @@
1
+ /**
2
+ * Server-rendered pages. One small script (public/app.js) votes without a page
3
+ * load, copies snippets, shows progress while we read a site, and registers the
4
+ * service worker; everything works without it.
5
+ */
6
+ import { createHash } from 'node:crypto'
7
+ import { readFileSync } from 'node:fs'
8
+ import type { Maker, Product, User, Window } from './db.js'
9
+ import { RING_PLACEHOLDER } from './ring.js'
10
+ import { CHAIN_LABELS } from './service.js'
11
+ import { icon, SOCIAL_ICONS } from './icons.js'
12
+
13
+ export const REPO = 'https://github.com/moshcoder/built.indie'
14
+
15
+ /** crawlproof analytics on every page (project 71b194a4…); CRAWLPROOF_SITE=off turns it off. */
16
+ const TRACK_SITE = process.env.CRAWLPROOF_SITE ?? '71b194a4-9ae7-4b79-9ec1-0cf0d5ef203e'
17
+ const TRACKER = TRACK_SITE === 'off' ? '' : `<script data-site="${TRACK_SITE}" src="https://crawlproof.com/stats.js" async></script>`
18
+
19
+ /**
20
+ * A crawlproof ad: a cross-origin, sandboxed iframe, so it can never read the
21
+ * page. Only on pages where nobody is typing: never on submit or edit.
22
+ * CRAWLPROOF_AD_SLOT overrides the slot; 'off' turns ads off.
23
+ */
24
+ export const AD_SLOT = process.env.CRAWLPROOF_AD_SLOT ?? '7371269e-a3c9-4a84-8b22-00d5045068e7'
25
+ export const adUnit = (format: 'banner_300x250' | 'banner_728x90' | 'text_link') =>
26
+ AD_SLOT === 'off' ? '' : `<div class="ad-unit ad-${format}"><iframe src="https://crawlproof.com/api/ads/frame?slot=${encodeURIComponent(AD_SLOT)}&amp;format=${format}" title="Advertisement" loading="lazy" referrerpolicy="strict-origin-when-cross-origin" sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation" scrolling="no"></iframe></div>`
27
+
28
+ const asset = (name: string) => {
29
+ const body = readFileSync(new URL(`../public/${name}`, import.meta.url))
30
+ return `/${name}?v=${createHash('sha256').update(body).digest('hex').slice(0, 10)}`
31
+ }
32
+ const STYLE_URL = asset('style.css')
33
+ const SCRIPT_URL = asset('app.js')
34
+
35
+ const ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
36
+ export const esc = (value: unknown) => String(value ?? '').replace(/[&<>"']/g, (c) => ESC[c]!)
37
+ const enc = encodeURIComponent
38
+
39
+ const LOGO = `<svg class="mark" viewBox="0 0 32 32" aria-hidden="true"><path d="M16 4 5 17h7v11h8V17h7z" fill="currentColor"/></svg>`
40
+
41
+ export type Ctx = { origin: string; user?: User | null; voted?: (id: string) => boolean }
42
+
43
+ type LayoutOptions = { title: string; description: string; path: string; body: string; ctx: Ctx; noindex?: boolean; image?: string | null; head?: string; ads?: boolean }
44
+
45
+ function layout(o: LayoutOptions): string {
46
+ const url = o.ctx.origin + o.path
47
+ return `<!doctype html>
48
+ <html lang="en">
49
+ <head>
50
+ <meta charset="utf-8">
51
+ <meta name="viewport" content="width=device-width, initial-scale=1">
52
+ <title>${esc(o.title)}</title>
53
+ <meta name="description" content="${esc(o.description)}">
54
+ <meta property="og:type" content="website">
55
+ <meta property="og:site_name" content="built.indie">
56
+ <meta property="og:title" content="${esc(o.title)}">
57
+ <meta property="og:description" content="${esc(o.description)}">
58
+ <meta property="og:url" content="${esc(url)}">
59
+ ${o.image ? `<meta property="og:image" content="${esc(o.image)}"><meta name="twitter:card" content="summary_large_image"><meta name="twitter:image" content="${esc(o.image)}">` : '<meta name="twitter:card" content="summary">'}
60
+ <meta name="referrer" content="strict-origin-when-cross-origin">
61
+ <meta name="color-scheme" content="dark light">
62
+ <meta name="theme-color" content="#0b0a10">
63
+ ${o.noindex ? '<meta name="robots" content="noindex">' : `<link rel="canonical" href="${esc(url)}">`}
64
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml">
65
+ <link rel="apple-touch-icon" href="/icon-192.png">
66
+ <link rel="manifest" href="/manifest.webmanifest">
67
+ <link rel="alternate" type="application/rss+xml" title="built.indie launches" href="/feed.xml">
68
+ <link rel="openprofile" href="/.well-known/openprofile.md">
69
+ <link rel="openicon" href="/icons/openicon.json">
70
+ <link rel="openstack" href="/.well-known/openstack.md">
71
+ ${o.head ?? ''}
72
+ <link rel="stylesheet" href="${STYLE_URL}">
73
+ <script src="${SCRIPT_URL}" defer></script>
74
+ ${TRACKER}
75
+ </head>
76
+ <body>
77
+ ${RING_PLACEHOLDER}
78
+ <header class="top">
79
+ <a class="brand" href="/">${LOGO}<span>built<b>.</b>indie</span></a>
80
+ <nav aria-label="Site">
81
+ <a href="/top/today">Today</a>
82
+ <a href="/top/week">Week</a>
83
+ <a href="/new">New</a>
84
+ <a href="/help">API</a>
85
+ ${o.ctx.user ? '<a href="/me">Mine</a>' : '<a href="/login">Sign in</a>'}
86
+ <a class="btn small-btn" href="/submit">${icon('rocket')} Launch</a>
87
+ </nav>
88
+ </header>
89
+ <main>
90
+ ${o.body}
91
+ </main>
92
+ ${o.ads ? adUnit('text_link') : ''}
93
+ <footer>
94
+ <span>built.indie: launch your indie product with one URL. Free, open source.</span>
95
+ <span><a href="/help">API · CLI · MCP</a> · <a href="/feed.xml">RSS</a> · <a href="${REPO}">Source</a> · <a href="https://pit.world/ring">Moshpit ring</a></span>
96
+ </footer>
97
+ </body>
98
+ </html>`
99
+ }
100
+
101
+ export const mediaUrl = (p: Product, file: string | null) => (file ? `/m/${p.id}/${file}` : null)
102
+ export const cardImage = (origin: string, p: Product) => {
103
+ const f = p.ogImage ?? p.screenshot ?? p.images[0] ?? null
104
+ return f ? `${origin}/m/${p.id}/${f}` : null
105
+ }
106
+
107
+ function logoImg(p: Product, size = 48): string {
108
+ const src = mediaUrl(p, p.logo)
109
+ return src
110
+ ? `<img class="logo" src="${esc(src)}" width="${size}" height="${size}" alt="" loading="lazy">`
111
+ : `<span class="logo logo-letter" style="width:${size}px;height:${size}px">${esc(p.name.slice(0, 1).toUpperCase())}</span>`
112
+ }
113
+
114
+ function voteButton(ctx: Ctx, p: Product, big = false): string {
115
+ const voted = ctx.voted?.(p.id) ?? false
116
+ return `<form class="vote-form" method="post" action="/p/${esc(p.slug)}/vote">
117
+ <button class="vote${big ? ' big' : ''}${voted ? ' voted' : ''}" data-vote="${esc(p.slug)}" aria-label="Upvote ${esc(p.name)}"${voted ? ' aria-pressed="true"' : ''}><span class="arrow">${icon('arrow-up')}</span><span class="count">${p.votes}</span></button>
118
+ </form>`
119
+ }
120
+
121
+ function row(ctx: Ctx, p: Product, rank?: number): string {
122
+ return `<li class="row-item">
123
+ ${rank ? `<span class="rank">${rank}</span>` : ''}
124
+ <a class="row-main" href="/p/${esc(p.slug)}">
125
+ ${logoImg(p)}
126
+ <span class="row-text"><b>${esc(p.name)}</b>${p.verifiedAt ? ' <span class="tick" title="Links back to built.indie">✓</span>' : ''}<span class="muted">${esc(p.tagline || p.domain)}</span>
127
+ ${p.tags.length ? `<span class="tags">${p.tags.slice(0, 3).map((t) => `<span class="tag">${esc(t)}</span>`).join('')}</span>` : ''}</span>
128
+ </a>
129
+ ${voteButton(ctx, p)}
130
+ </li>`
131
+ }
132
+
133
+ const board = (ctx: Ctx, products: Product[], ranked = true) => (products.length ? `<ol class="board">${products.map((p, i) => row(ctx, p, ranked ? i + 1 : undefined)).join('')}</ol>` : '')
134
+
135
+ function submitForm(compact = false, values: Record<string, string> = {}, error?: { message: string; field?: string }): string {
136
+ return `<form class="submit-form${compact ? ' compact' : ''}" method="post" action="/submit" data-busy="Reading the homepage, llms.txt and manifest, saving the logo and screenshots…">
137
+ ${error ? `<p class="note error" role="alert">${esc(error.message)}</p>` : ''}
138
+ <label for="url"${compact ? ' class="sr"' : ''}>Your product’s URL</label>
139
+ <div class="url-row"><input id="url" name="url" type="text" inputmode="url" autocomplete="url" placeholder="yourproduct.com" required value="${esc(values.url ?? '')}"${error?.field === 'url' ? ' aria-invalid="true"' : ''}><button class="btn">Launch it</button></div>
140
+ ${compact ? '' : `<details${values.makerName || values.makerProfile ? ' open' : ''}><summary>Who made it? <span class="muted">(optional)</span></summary>
141
+ <label for="makerName">Your name</label><input id="makerName" name="makerName" maxlength="60" value="${esc(values.makerName ?? '')}">
142
+ <label for="makerProfile">Your OpenProfile.md <span class="muted">URL, e.g. yoursite.com/.well-known/openprofile.md (<a href="https://logicsrc.com/openprofile">what is it?</a>)</span></label><input id="makerProfile" name="makerProfile" type="text" inputmode="url" value="${esc(values.makerProfile ?? '')}"${error?.field === 'makerProfile' ? ' aria-invalid="true"' : ''}>
143
+ </details>`}
144
+ <p class="muted small">Free. We read the homepage: name, tagline, logo, screenshots, videos, socials, <code>llms.txt</code>, and the <a href="https://logicsrc.com/specs">LogicSRC</a> files: <code>opensite.json</code>, <code>openprofile.md</code>, <code>openstack.md</code>, <code>opensaas.json</code>, <code>openicon.json</code>. Anyone can launch any site; the owner claims it with a DNS record.</p>
145
+ </form>`
146
+ }
147
+
148
+ export function homePage(ctx: Ctx, d: { today: Product[]; featured: Product[]; yesterday: Product[]; stats: { products: number; votes: number } }): string {
149
+ return layout({
150
+ ctx,
151
+ title: 'built.indie: launch your indie product with one URL',
152
+ description: 'Paste a URL. We fetch everything: logo, screenshots, videos, llms.txt. Get upvotes and a badge. Free.',
153
+ path: '/',
154
+ ads: true,
155
+ body: `<section class="hero">
156
+ <h1>Built it? Launch it.</h1>
157
+ <p class="lede">Paste one URL. We pull your logo, screenshots, videos and story from your homepage. People upvote. You get a badge.</p>
158
+ ${submitForm(true)}
159
+ <p class="muted small">${d.stats.products} launched · ${d.stats.votes} upvotes · free, no sign-up to launch</p>
160
+ </section>
161
+ ${d.featured.length ? `<h2 class="section-h">Featured</h2>${board(ctx, d.featured, false)}` : ''}
162
+ <h2 class="section-h">Launching today <span class="muted small">resets at 00:00 UTC</span></h2>
163
+ ${board(ctx, d.today) || `<p class="empty">Nothing launched yet today. <a href="/submit">Be first</a>: first launch of the day gets the top spot until someone beats it.</p>`}
164
+ ${d.yesterday.length ? `<h2 class="section-h">Yesterday’s winners</h2>${board(ctx, d.yesterday.slice(0, 3))}` : ''}
165
+ <p class="center"><a href="/top/week">This week</a> · <a href="/top/month">This month</a> · <a href="/top/all">All time</a> · <a href="/new">Newest</a></p>
166
+ ${adUnit('banner_728x90')}`,
167
+ })
168
+ }
169
+
170
+ const WINDOW_TITLES: Record<Window, string> = { today: 'Top today', week: 'Top this week', month: 'Top this month', all: 'Top of all time', new: 'Newest launches', featured: 'Featured' }
171
+
172
+ export function boardPage(ctx: Ctx, window: Window, products: Product[], q?: string): string {
173
+ const tabs = (['today', 'week', 'month', 'all', 'new'] as Window[]).map((w) => `<a href="${w === 'new' ? '/new' : `/top/${w}`}"${w === window && !q ? ' aria-current="page"' : ''}>${esc(WINDOW_TITLES[w].replace(/^Top (this )?|^Top of /, '').replace(/^Newest launches$/, 'New'))}</a>`).join('')
174
+ return layout({
175
+ ctx,
176
+ title: q ? `“${q}” on built.indie` : `${WINDOW_TITLES[window]} · built.indie`,
177
+ description: `${WINDOW_TITLES[window]}: indie products launched on built.indie.`,
178
+ path: q ? `/search?q=${enc(q)}` : window === 'new' ? '/new' : `/top/${window}`,
179
+ noindex: Boolean(q),
180
+ ads: true,
181
+ body: `<h1 class="page-h">${q ? `Search: ${esc(q)}` : esc(WINDOW_TITLES[window])}</h1>
182
+ <nav class="tabs" aria-label="Boards">${tabs}</nav>
183
+ <form class="search" method="get" action="/search"><label class="sr" for="q">Search</label><input id="q" name="q" placeholder="Search launches" value="${esc(q ?? '')}"></form>
184
+ ${board(ctx, products, window !== 'new') || '<p class="empty">Nothing here yet.</p>'}
185
+ ${adUnit('banner_300x250')}`,
186
+ })
187
+ }
188
+
189
+ function share(origin: string, p: Product): string {
190
+ const url = `${origin}/p/${p.slug}`
191
+ const text = `${p.name}: ${p.tagline || p.domain}. Launched on built.indie, upvote it 👇`
192
+ const links: [string, string][] = [
193
+ ['X', `https://x.com/intent/post?text=${enc(text)}&url=${enc(url)}`],
194
+ ['Bluesky', `https://bsky.app/intent/compose?text=${enc(`${text} ${url}`)}`],
195
+ ['LinkedIn', `https://www.linkedin.com/sharing/share-offsite/?url=${enc(url)}`],
196
+ ['Reddit', `https://www.reddit.com/submit?url=${enc(url)}&title=${enc(`${p.name}: ${p.tagline}`)}`],
197
+ ['Hacker News', `https://news.ycombinator.com/submitlink?u=${enc(p.url)}&t=${enc(`Show HN: ${p.name} – ${p.tagline}`)}`],
198
+ ]
199
+ const icons: Record<string, string> = { X: 'x', Bluesky: 'bluesky', LinkedIn: 'linkedin', Reddit: 'reddit', 'Hacker News': 'code' }
200
+ return `<div class="share">${links.map(([n, h]) => `<a class="chip" href="${esc(h)}" target="_blank" rel="noopener">${icon(icons[n] ?? 'share')} ${esc(n)}</a>`).join('')}<button class="chip" data-copy="${esc(url)}">${icon('link')} Copy link</button></div>`
201
+ }
202
+
203
+ export function badgeSnippet(origin: string, p: Product): string {
204
+ return `<a href="${origin}/p/${p.slug}?ref=badge"><img src="${origin}/badge/${p.slug}.svg" alt="${p.name} on built.indie" width="200" height="44"></a>`
205
+ }
206
+
207
+ /** "Built with", from the product's own OpenStack.md. */
208
+ function stackHtml(p: Product): string {
209
+ const s = p.extras.stack
210
+ if (!s) return ''
211
+ const layers = new Map<string, string[]>()
212
+ for (const i of s.items) layers.set(i.layer, [...(layers.get(i.layer) ?? []), i.name])
213
+ return `<div class="stack"><h2 class="mini-h">${icon('layers')} Built with</h2>${[...layers].slice(0, 8).map(([layer, names]) => `<p><span class="muted small">${esc(layer)}</span> ${names.slice(0, 8).map((n) => `<span class="tag">${esc(n)}</span>`).join(' ')}</p>`).join('')}</div>`
214
+ }
215
+
216
+ /** Plans and the way out, from the product's own OpenSaaS file. */
217
+ function pricingHtml(p: Product): string {
218
+ const s = p.extras.pricing
219
+ if (!s) return ''
220
+ const price = (n: number | null) => (n === null ? '' : n === 0 ? 'Free' : `${s.currency === 'USD' ? '$' : `${s.currency} `}${n}`)
221
+ return `<div class="pricing"><h2 class="mini-h">${icon('dollar')} Pricing</h2><ul class="plans">${s.plans.map((pl) => `<li><b>${pl.url ? `<a href="${esc(pl.url)}" rel="nofollow noopener">${esc(pl.name)}</a>` : esc(pl.name)}</b> ${esc(price(pl.price))}${pl.period && pl.price ? `/${esc(pl.period)}` : ''}${pl.trial ? ` <span class="muted small">· ${esc(pl.trial)} trial</span>` : ''}</li>`).join('')}</ul>${s.cancel ? `<p class="small muted">The way out: <a href="${esc(s.cancel)}" rel="nofollow noopener">cancel page</a></p>` : ''}</div>`
222
+ }
223
+
224
+ const SOCIAL_LABELS: Record<string, string> = { x: 'X', github: 'GitHub', linkedin: 'LinkedIn', discord: 'Discord', youtube: 'YouTube', bluesky: 'Bluesky', instagram: 'Instagram', tiktok: 'TikTok', reddit: 'Reddit', telegram: 'Telegram', producthunt: 'Product Hunt', appstore: 'App Store', playstore: 'Google Play', chrome: 'Chrome Web Store' }
225
+
226
+ export function productPage(ctx: Ctx, d: { product: Product; award: { day: string; rank: number } | null; maker: Maker | null; featuredOk?: boolean; launched?: boolean; canEdit?: boolean; chains: string[]; featureUsd: number; featureDays: number; payments: boolean }): string {
227
+ const p = d.product
228
+ const shots = [p.screenshot, ...p.images, p.ogImage].filter((f): f is string => Boolean(f))
229
+ const rel = p.verifiedAt ? 'noopener' : 'noopener nofollow ugc'
230
+ const live = p.status === 'live'
231
+ const awardText = d.award ? `#${d.award.rank} Product of the day · ${d.award.day}` : ''
232
+ return layout({
233
+ ctx,
234
+ title: `${p.name}: ${p.tagline || p.domain} · built.indie`,
235
+ description: p.description || p.tagline || `${p.name} on built.indie`,
236
+ path: `/p/${p.slug}`,
237
+ image: cardImage(ctx.origin, p),
238
+ noindex: !live,
239
+ ads: live,
240
+ head: `<link rel="opensite" href="/p/${esc(p.slug)}/opensite.json"><link rel="alternate" type="text/markdown" title="OpenProfile" href="/p/${esc(p.slug)}/openprofile.md">`,
241
+ body: `${d.launched && live ? `<div class="note launched"><b>🚀 ${esc(p.name)} is live.</b> Today’s board resets at midnight UTC: share it now. ${p.ownerId ? '' : `Is it yours? <a href="/p/${esc(p.slug)}/claim">Claim it</a> with a DNS record to edit it and get the ✓.`}</div>` : ''}
242
+ ${d.featuredOk ? '<p class="note">Thanks! Your payment is on its way; the Featured spot starts the moment CoinPay confirms it.</p>' : ''}
243
+ ${!live ? `<p class="note">${p.status === 'draft' ? 'Draft: only people with the edit link see this.' : 'Taken down.'}</p>` : ''}
244
+ <section class="product-head">
245
+ ${logoImg(p, 72)}
246
+ <div class="product-title">
247
+ <h1>${esc(p.name)}</h1>
248
+ <p class="lede">${esc(p.tagline)}</p>
249
+ <p class="badges">${awardText ? `<span class="pill gold">🏆 ${esc(awardText)}</span>` : ''}${p.featuredUntil && p.featuredUntil > Date.now() ? '<span class="pill">★ Featured</span>' : ''}${p.verifiedAt ? '<span class="pill ok" title="Their homepage links back to built.indie">✓ Verified</span>' : ''}${p.llms ? `<a class="pill" href="${esc(p.llms.url)}" rel="nofollow noopener">llms.txt</a>` : ''}${p.profile ? `<a class="pill" href="${esc(p.profile.url)}" rel="nofollow noopener">OpenProfile</a>` : ''}${p.record?.source === 'declared' ? '<span class="pill">OpenSite</span>' : ''}${p.extras.stack ? `<a class="pill" href="${esc(p.extras.stack.url)}" rel="nofollow noopener">OpenStack</a>` : ''}${p.extras.pricing ? `<a class="pill" href="${esc(p.extras.pricing.url)}" rel="nofollow noopener">OpenSaaS</a>` : ''}${p.extras.openicon ? `<a class="pill" href="${esc(p.extras.openicon)}" rel="nofollow noopener">OpenIcon</a>` : ''}</p>
250
+ </div>
251
+ <div class="product-actions">
252
+ ${live ? voteButton(ctx, p, true) : ''}
253
+ <a class="btn ghost" href="${esc(p.url)}" rel="${rel}" target="_blank">Visit ${esc(p.domain)} ${icon('external-link')}</a>
254
+ ${d.canEdit ? `<a class="chip" href="/p/${esc(p.slug)}/edit">Edit</a>` : p.ownerId ? '<span class="muted small">Claimed by its owner</span>' : `<a class="chip" href="/p/${esc(p.slug)}/claim">Is this yours? Claim it</a>`}
255
+ </div>
256
+ </section>
257
+ ${shots.length ? `<div class="gallery">${shots.map((f, i) => `<a href="${esc(mediaUrl(p, f)!)}" target="_blank"><img src="${esc(mediaUrl(p, f)!)}" alt="${esc(p.name)} screenshot ${i + 1}" loading="${i ? 'lazy' : 'eager'}"></a>`).join('')}</div>` : ''}
258
+ ${p.videos.length ? `<div class="videos">${p.videos.map((v) => (v.embed ? `<div class="video"><iframe src="${esc(v.embed)}" title="${esc(p.name)} video" loading="lazy" allow="fullscreen; picture-in-picture" allowfullscreen referrerpolicy="strict-origin-when-cross-origin" sandbox="allow-scripts allow-same-origin allow-popups allow-presentation"></iframe></div>` : `<p><a href="${esc(v.url)}" rel="nofollow noopener" target="_blank">▶ Watch the video</a></p>`)).join('')}</div>` : ''}
259
+ <div class="card">
260
+ ${p.description ? `<p class="desc">${esc(p.description)}</p>` : ''}
261
+ ${p.tags.length ? `<p class="tags">${p.tags.map((t) => `<a class="tag" href="/search?q=${enc(t)}">${esc(t)}</a>`).join('')}</p>` : ''}
262
+ ${Object.keys(p.socials).length ? `<p class="socials">${Object.entries(p.socials).map(([k, v]) => `<a class="chip" href="${esc(v)}" rel="nofollow noopener" target="_blank">${icon(SOCIAL_ICONS[k] ?? 'globe')} ${esc(SOCIAL_LABELS[k] ?? k)}</a>`).join('')}</p>` : ''}
263
+ ${stackHtml(p)}
264
+ ${pricingHtml(p)}
265
+ ${p.makerName || d.maker ? `<p class="maker">Made by ${d.maker ? `<a href="/makers/${esc(d.maker.id)}">${esc(p.makerName || d.maker.name)}</a>${p.makerVerified ? ' <span class="tick" title="Their OpenProfile names this product">✓</span>' : ''}` : esc(p.makerName)}</p>` : ''}
266
+ <p class="muted small">Launched ${p.launchedAt ? new Date(p.launchedAt).toISOString().slice(0, 10) : 'soon'} · ${p.votes} upvote${p.votes === 1 ? '' : 's'} · <a href="/p/${esc(p.slug)}/opensite.json">OpenSite record</a> · <a href="/p/${esc(p.slug)}/openprofile.md">OpenProfile.md</a></p>
267
+ </div>
268
+ ${live ? `<h2 class="section-h">Share it</h2>${share(ctx.origin, p)}
269
+ <details class="card" id="badge"${p.verifiedAt || !d.canEdit ? '' : ' open'}>
270
+ <summary>Add the badge to your site</summary>
271
+ <p class="muted">Put this on your homepage. It shows your live upvote count, and once we see it your link here becomes a followed link and you get the ✓.</p>
272
+ <p><img src="/badge/${esc(p.slug)}.svg" alt="badge" width="200" height="44"> <img src="/badge/${esc(p.slug)}.svg?theme=light" alt="badge, light" width="200" height="44"></p>
273
+ <pre><code>${esc(badgeSnippet(ctx.origin, p))}</code></pre>
274
+ <div class="row"><button class="chip" data-copy="${esc(badgeSnippet(ctx.origin, p))}">Copy badge HTML</button><form method="post" action="/p/${esc(p.slug)}/verify"><button class="chip">I added it: check</button></form></div>
275
+ </details>
276
+ ${d.payments ? `<details class="card" id="feature"><summary>Feature it: ${esc(`$${d.featureUsd}`)} for ${d.featureDays} days</summary>
277
+ <p class="muted">Pinned above today’s board on the homepage for ${d.featureDays} days. Paid in crypto through CoinPay.</p>
278
+ <form method="post" action="/p/${esc(p.slug)}/feature" class="row"><label class="sr" for="chain">Pay with</label><select id="chain" name="chain">${d.chains.map((c) => `<option value="${esc(c)}">${esc(CHAIN_LABELS[c] ?? c)}</option>`).join('')}</select><button class="btn">Pay $${esc(d.featureUsd)}</button></form>
279
+ </details>` : ''}
280
+ ${adUnit('banner_300x250')}` : ''}`,
281
+ })
282
+ }
283
+
284
+ export function submitPage(ctx: Ctx, d: { values?: Record<string, string>; error?: { message: string; field?: string } } = {}): string {
285
+ return layout({
286
+ ctx,
287
+ title: 'Launch your product · built.indie',
288
+ description: 'Paste your URL; we fetch the rest. Free, no account.',
289
+ path: '/submit',
290
+ body: `<h1 class="page-h">Launch your product</h1>
291
+ <div class="card">${submitForm(false, d.values, d.error)}</div>
292
+ <div class="grid3">
293
+ <div class="card"><h2><span class="step-n">1</span>Paste</h2><p class="muted">Your URL. We read the page like a browser, plus <code>/llms.txt</code>, <code>/.well-known/opensite.json</code> and your <code>openprofile.md</code>.</p></div>
294
+ <div class="card"><h2><span class="step-n">2</span>Check</h2><p class="muted">Fix the name or tagline, drop a screenshot, add a video. Then publish.</p></div>
295
+ <div class="card"><h2><span class="step-n">3</span>Share</h2><p class="muted">Today’s board resets at midnight UTC. Share your link, add the badge, collect upvotes.</p></div>
296
+ </div>`,
297
+ })
298
+ }
299
+
300
+ export function editPage(ctx: Ctx, d: { product: Product; fresh?: boolean; flash?: string; error?: { message: string; field?: string } }): string {
301
+ const p = d.product
302
+ const pics = [p.logo, p.ogImage, p.screenshot, ...p.images].filter((f): f is string => Boolean(f))
303
+ const label = (f: string) => (f === p.logo ? 'Logo' : f === p.ogImage ? 'Card image' : f === p.screenshot ? 'Homepage screenshot' : 'From your page')
304
+ return layout({
305
+ ctx,
306
+ title: `Edit ${p.name} · built.indie`,
307
+ description: 'Edit your launch.',
308
+ path: `/p/${p.slug}/edit`,
309
+ noindex: true,
310
+ body: `${d.fresh ? `<p class="note">Here is what we read from ${esc(p.domain)}. Fix anything, then launch. It is in <a href="/me">your launches</a>${p.ownerId ? '' : `; <a href="/p/${esc(p.slug)}/claim">claim it</a> with a DNS record so it stays yours`}.</p>` : ''}
311
+ ${d.flash ? `<p class="note">${esc(d.flash)}</p>` : ''}
312
+ ${d.error ? `<p class="note error" role="alert">${esc(d.error.message)}</p>` : ''}
313
+ <h1 class="page-h">${p.status === 'draft' ? 'Check it, then launch' : `Edit ${esc(p.name)}`}</h1>
314
+ <p class="muted">We read <a href="${esc(p.url)}" rel="nofollow noopener">${esc(p.domain)}</a>${p.record ? ` · found: ${esc(((p.record as any).cards ? Object.keys((p.record as any).cards) : []).concat(p.llms ? ['llms.txt'] : [], p.profile ? ['openprofile.md'] : [], (p.record as any).source === 'declared' ? ['opensite.json'] : []).join(', ') || 'the page itself')}` : ''}.</p>
315
+ <form method="post" action="/p/${esc(p.slug)}/edit" class="card">
316
+ <div class="product-head">${logoImg(p, 56)}<div class="product-title"><label for="name">Name</label><input id="name" name="name" maxlength="60" required value="${esc(p.name)}"></div></div>
317
+ <label for="tagline">Tagline <span class="muted">(one line, 90 characters)</span></label><input id="tagline" name="tagline" maxlength="90" value="${esc(p.tagline)}">
318
+ <label for="description">Description</label><textarea id="description" name="description" rows="5" maxlength="1200">${esc(p.description)}</textarea>
319
+ <label for="tags">Tags <span class="muted">(comma separated, up to 6)</span></label><input id="tags" name="tags" value="${esc(p.tags.join(', '))}">
320
+ <label for="videos">Videos <span class="muted">(YouTube, Vimeo, Loom or .mp4; one per line)</span></label><textarea id="videos" name="videos" rows="2">${esc(p.videos.map((v) => v.url).join('\n'))}</textarea>
321
+ <label for="makerName">Made by</label><input id="makerName" name="makerName" maxlength="60" value="${esc(p.makerName)}">
322
+ <label for="makerProfile">Maker’s OpenProfile.md <span class="muted">(<a href="https://logicsrc.com/openprofile">what is it?</a>)</span></label><input id="makerProfile" name="makerProfile" type="text" inputmode="url" value="${esc(p.makerProfile ?? '')}">
323
+ ${pics.length ? `<fieldset class="pics"><legend>Pictures <span class="muted">(tick to remove)</span></legend>${pics.map((f) => `<label class="pic"><img src="${esc(mediaUrl(p, f)!)}" alt=""><span><input type="checkbox" name="remove" value="${esc(f)}"> ${esc(label(f))}</span></label>`).join('')}</fieldset>` : '<p class="muted">No pictures found on your page. Add an og:image or a screenshot to your homepage and re-read it.</p>'}
324
+ <div class="row"><button class="btn ghost" name="action" value="save">Save</button>${p.status === 'draft' ? '<button class="btn" name="action" value="publish">Launch it 🚀</button>' : `<a class="btn ghost" href="/p/${esc(p.slug)}">View</a>`}</div>
325
+ </form>
326
+ <div class="row">
327
+ <form method="post" action="/p/${esc(p.slug)}/edit" data-busy="Reading your homepage again…"><button class="chip" name="action" value="refetch">Re-read my homepage</button></form>
328
+ <form method="post" action="/p/${esc(p.slug)}/edit" data-confirm="Delete ${esc(p.name)} for good?"><button class="chip danger" name="action" value="delete">Delete</button></form>
329
+ </div>`,
330
+ })
331
+ }
332
+
333
+ export function claimPage(ctx: Ctx, d: { product: Product; record: { name: string; value: string }; owner: boolean; result?: { ok: boolean; looked: string[] } }): string {
334
+ const p = d.product
335
+ const done = d.owner || d.result?.ok
336
+ return layout({
337
+ ctx,
338
+ title: `Claim ${p.name} · built.indie`,
339
+ description: `Prove you own ${p.domain} with a DNS record.`,
340
+ path: `/p/${p.slug}/claim`,
341
+ noindex: true,
342
+ body: `<h1 class="page-h">${done ? `${esc(p.name)} is yours ✓` : `Claim ${esc(p.name)}`}</h1>
343
+ ${done ? `<p class="note">You own this listing. <a href="/p/${esc(p.slug)}/edit">Edit it</a>, then add the <a href="/p/${esc(p.slug)}#badge">badge</a>.</p>` : `
344
+ ${p.ownerId ? '<p class="note">Someone already claimed this listing. If you own the domain, adding your record below moves it to you.</p>' : ''}
345
+ ${d.result && !d.result.ok ? `<p class="note error" role="alert">We did not find the record yet at ${d.result.looked.map((n) => `<code>${esc(n)}</code>`).join(' or ')}. DNS can take a few minutes; try again.</p>` : ''}
346
+ <p class="muted">Prove you control <b>${esc(p.domain)}</b>: add this TXT record at your DNS host, then check.</p>
347
+ <div class="card">
348
+ <p class="small muted">Type</p><p><code>TXT</code></p>
349
+ <p class="small muted">Name / host</p><div class="address"><code>${esc(d.record.name)}</code><button class="copy" data-copy="${esc(d.record.name.replace(`.${p.domain.replace(/^www\./, '')}`, ''))}">Copy</button></div>
350
+ <p class="small muted">Some DNS hosts want only <code>_built-indie</code> here; others want the full name.</p>
351
+ <p class="small muted">Value</p><div class="address"><code>${esc(d.record.value)}</code><button class="copy" data-copy="${esc(d.record.value)}">Copy</button></div>
352
+ <p class="small muted">A TXT record on ${esc(p.domain.replace(/^www\./, ''))} itself with the same value works too. You can remove it once claimed.</p>
353
+ <form method="post" action="/p/${esc(p.slug)}/claim" data-busy="Asking DNS…"><button class="btn">Check DNS</button></form>
354
+ </div>`}`,
355
+ })
356
+ }
357
+
358
+ export function loginPage(ctx: Ctx, d: { next: string | null; sent?: boolean; error?: string; email?: string }): string {
359
+ return layout({
360
+ ctx,
361
+ title: 'Sign in · built.indie',
362
+ description: 'Sign in with a link by email.',
363
+ path: '/login',
364
+ noindex: true,
365
+ body: `<section class="hero narrow"><h1>${d.sent ? 'Check your email' : 'Sign in'}</h1>
366
+ ${d.sent ? '<p class="lede">If that address can receive mail, a sign-in link is on its way. It works once, for 20 minutes.</p>' : `<p class="lede">No password. We email you a link; a new address makes an account. You need one only to claim and edit a listing. Launching is free and needs nothing.</p>
367
+ ${d.error ? `<p class="note error" role="alert">${esc(d.error)}</p>` : ''}
368
+ <form method="post" action="/login" class="card">
369
+ <input type="hidden" name="next" value="${esc(d.next ?? '')}">
370
+ <label for="email">Email</label><input id="email" name="email" type="email" autocomplete="email" required value="${esc(d.email ?? '')}">
371
+ <p><button class="btn wide">Email me a link</button></p>
372
+ </form>`}</section>`,
373
+ })
374
+ }
375
+
376
+ export function mePage(ctx: Ctx, d: { user: User; products: Product[]; apiKey?: string }): string {
377
+ return layout({
378
+ ctx,
379
+ title: 'Your launches · built.indie',
380
+ description: 'Your launches.',
381
+ path: '/me',
382
+ noindex: true,
383
+ body: `<h1 class="page-h">Your launches</h1>
384
+ <p class="muted">Signed in as ${esc(d.user.email)}. <form class="inline" method="post" action="/logout"><button class="linkish">Sign out</button></form></p>
385
+ ${d.products.length ? `<ol class="board">${d.products.map((p) => `<li class="row-item"><a class="row-main" href="/p/${esc(p.slug)}">${logoImg(p)}<span class="row-text"><b>${esc(p.name)}</b><span class="muted">${p.status === 'draft' ? 'draft' : `▲ ${p.votes}`} · ${p.ownerId === d.user.id ? 'claimed ✓' : 'submitted by you, not claimed'}</span></span></a><a class="chip" href="/p/${esc(p.slug)}/edit">Edit</a>${p.ownerId === d.user.id ? '' : `<a class="chip" href="/p/${esc(p.slug)}/claim">Claim</a>`}</li>`).join('')}</ol>` : '<p class="empty">Nothing yet. <a href="/submit">Launch something</a>, or open a listing that is yours and claim it.</p>'}
386
+ <div class="card" id="api"><h2>API key</h2>
387
+ ${d.apiKey ? `<p class="note"><b>Copy it now</b>, we show it once. Making a new one retires this one.</p><div class="address"><code>${esc(d.apiKey)}</code><button class="copy" data-copy="${esc(d.apiKey)}">Copy</button></div><pre>export BUILTINDIE_KEY=${esc(d.apiKey)}</pre>` : '<p class="muted">For the CLI, the TUI and the MCP server: edit and claim from the terminal or an agent.</p>'}
388
+ <form method="post" action="/me/key"><button class="chip">${d.apiKey ? 'Make another' : 'Make an API key'}</button></form></div>`,
389
+ })
390
+ }
391
+
392
+ export function makerPage(ctx: Ctx, d: { maker: Maker; products: Product[] }): string {
393
+ const m = d.maker
394
+ return layout({
395
+ ctx,
396
+ title: `${m.name} · built.indie`,
397
+ description: m.headline || `What ${m.name} launched on built.indie`,
398
+ path: `/makers/${m.id}`,
399
+ ads: true,
400
+ head: `<link rel="alternate" type="text/markdown" title="OpenProfile" href="/makers/${esc(m.id)}/openprofile.md">`,
401
+ body: `<section class="payee"><h1>${esc(m.name)}</h1><p class="lede">${esc(m.headline)}</p>
402
+ <p class="socials">${[m.profileUrl, ...m.accounts].slice(0, 8).map((a) => `<a class="chip" href="${esc(a)}" rel="nofollow noopener me">${esc(a.replace(/^https?:\/\/(www\.)?/, '').slice(0, 40))}</a>`).join('')}</p></section>
403
+ <h2 class="section-h">Launched</h2>${board(ctx, d.products, false) || '<p class="empty">Nothing live yet.</p>'}
404
+ <p class="muted small"><a href="/makers/${esc(m.id)}/openprofile.md">OpenProfile.md</a></p>`,
405
+ })
406
+ }
407
+
408
+ export function helpPage(ctx: Ctx): string {
409
+ const o = ctx.origin
410
+ return layout({
411
+ ctx,
412
+ title: 'API, CLI and MCP · built.indie',
413
+ description: 'Launch, read and upvote from code: a JSON API, a CLI, a TUI and an MCP server.',
414
+ path: '/help',
415
+ body: `<h1 class="page-h">Launch from anywhere</h1>
416
+ <p class="muted">Everything on the site is an API. Launching and reading need nothing. To edit, sign in, claim your product with a DNS record, and make an API key on <a href="/me">your page</a>.</p>
417
+ <div class="card"><h2>API</h2>
418
+ <pre>curl -X POST ${o}/api/v1/products -H 'content-type: application/json' \\
419
+ -d '{"url":"yourproduct.com","maker_name":"You"}'
420
+ # → { product, claim_url } free; live at once
421
+
422
+ curl ${o}/api/v1/products?window=today # today|week|month|all|new|featured
423
+ curl ${o}/api/v1/products/&lt;slug&gt;
424
+ curl -X POST ${o}/api/v1/products/&lt;slug&gt;/vote
425
+ curl -X POST ${o}/api/v1/preview -d '{"url":"any.site"}' -H 'content-type: application/json'
426
+ curl ${o}/api/v1/products/&lt;slug&gt;/claim -H 'authorization: Bearer bi_…' # the TXT record to add
427
+ curl -X POST ${o}/api/v1/products/&lt;slug&gt;/claim -H 'authorization: Bearer bi_…' # check it
428
+ curl -X PATCH ${o}/api/v1/products/&lt;slug&gt; -H 'authorization: Bearer bi_…' \\
429
+ -H 'content-type: application/json' -d '{"tagline":"…","videos":["https://youtu.be/…"]}'
430
+ curl -X POST ${o}/api/v1/products/&lt;slug&gt;/refetch -H 'authorization: Bearer bi_…'
431
+ curl -X POST ${o}/api/v1/products/&lt;slug&gt;/publish -H 'authorization: Bearer bi_…'</pre>
432
+ <p class="muted small">OpenSite (logicsrc.com/docs/opensite): <code>GET /api/v1/sites?url=…</code>, <code>POST /api/v1/sites</code>, and every product at <code>/p/&lt;slug&gt;/opensite.json</code>. OpenProfile: <code>/p/&lt;slug&gt;/openprofile.md</code>, <code>/makers/&lt;id&gt;/openprofile.md</code>.</p></div>
433
+ <div class="card" id="cli"><h2>CLI and TUI</h2>
434
+ <pre>curl -fsSL ${o}/install.sh | sh # installs builtindie
435
+ builtindie # TUI: today's board, vote, launch
436
+ bunx @profullstack/builtindie # or run it without installing
437
+ bunx @profullstack/builtindie launch yourproduct.com
438
+ bunx @profullstack/builtindie top week
439
+ bunx @profullstack/builtindie show &lt;slug&gt;
440
+ bunx @profullstack/builtindie vote &lt;slug&gt;
441
+ BUILTINDIE_KEY=bi_… bunx @profullstack/builtindie claim &lt;slug&gt; # TXT record, then checks it
442
+ bunx @profullstack/builtindie preview any.site --json</pre></div>
443
+ <div class="card" id="mcp"><h2>MCP</h2>
444
+ <pre>claude mcp add builtindie -- bunx @profullstack/builtindie mcp</pre>
445
+ <p class="muted">Tools: <code>launch_product</code>, <code>preview_site</code>, <code>list_products</code>, <code>get_product</code>, <code>upvote</code>, <code>search</code>, and with <code>BUILTINDIE_KEY</code>: <code>claim_product</code>, <code>edit_product</code>, <code>my_products</code>.</p></div>
446
+ <div class="card" id="bot"><h2>Our reader</h2>
447
+ <p class="muted">We fetch only what a person submits, once, plus a re-read when the maker asks. User agent <code>built.indie/1.0</code>. We honour <code>robots.txt</code> and <code>index.allow: false</code> in <code>/.well-known/opensite.json</code>. Screenshots are taken with headless Chrome.</p></div>
448
+ <div class="card"><h2>Make your page read well</h2>
449
+ <ul class="muted"><li><code>og:image</code> 1200×630 and an <code>apple-touch-icon</code> or a manifest icon 512×512</li><li><code>/llms.txt</code>: <code># Name</code>, then <code>&gt; one-line summary</code></li><li><code>/.well-known/opensite.json</code> (<a href="https://logicsrc.com/opensite">OpenSite</a>) to say it yourself</li><li><code>/.well-known/openprofile.md</code> (<a href="https://logicsrc.com/openprofile">OpenProfile</a>) with <code>Kind: organization</code> and your accounts</li><li><code>/.well-known/openstack.md</code> (<a href="https://logicsrc.com/docs/openstack">OpenStack.md</a>): we show it as “Built with”</li><li><code>/.well-known/opensaas.json</code> (<a href="https://logicsrc.com/opensaas">OpenSaaS</a>): your plans and your cancel page</li><li><code>/.well-known/openicon.json</code> (<a href="https://logicsrc.com/docs/openicon">OpenIcon</a>) if you publish an icon set</li><li>a YouTube/Vimeo/Loom embed or <code>og:video</code> for your demo</li></ul></div>`,
450
+ })
451
+ }
452
+
453
+ export function messagePage(ctx: Ctx, d: { title: string; message: string; status?: number }): string {
454
+ return layout({ ctx, title: `${d.title} · built.indie`, description: d.message, path: '/', noindex: true, body: `<section class="hero"><h1>${esc(d.title)}</h1><p class="lede">${esc(d.message)}</p><a class="btn" href="/">Home</a></section>` })
455
+ }
456
+
457
+ /** The embeddable badge: live upvotes, or the day's award once it is won. */
458
+ export function badgeSvg(p: Product, award: { day: string; rank: number } | null, theme: 'dark' | 'light'): string {
459
+ const dark = theme === 'dark'
460
+ const bg = dark ? '#111018' : '#ffffff'
461
+ const fg = dark ? '#f4f1ff' : '#14121c'
462
+ const sub = dark ? '#a7a1bd' : '#5d5870'
463
+ const accent = '#ff5c39'
464
+ const top = award ? `#${award.rank} PRODUCT OF THE DAY` : 'LAUNCHED ON'
465
+ const count = String(p.votes)
466
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="200" height="44" viewBox="0 0 200 44" role="img" aria-label="${esc(p.name)}: ${esc(top.toLowerCase())} built.indie, ${count} upvotes">
467
+ <rect x=".5" y=".5" width="199" height="43" rx="9" fill="${bg}" stroke="${accent}"/>
468
+ <path d="M22 9 12 21h6v12h8V21h6z" fill="${accent}"/>
469
+ <text x="42" y="18" font-family="system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="8.5" font-weight="700" letter-spacing=".6" fill="${sub}">${esc(top)}</text>
470
+ <text x="42" y="33" font-family="system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="15" font-weight="800" fill="${fg}">built.indie</text>
471
+ <line x1="150" y1="9" x2="150" y2="35" stroke="${dark ? '#2a2736' : '#e6e3ee'}"/>
472
+ <text x="175" y="20" text-anchor="middle" font-family="system-ui,sans-serif" font-size="10" fill="${accent}">▲</text>
473
+ <text x="175" y="34" text-anchor="middle" font-family="system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="13" font-weight="800" fill="${fg}">${esc(count)}</text>
474
+ </svg>`
475
+ }
package/src/icons.ts ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Icons from the OpenIcon reference set (logicsrc.com/docs/openicon,
3
+ * github.com/profullstack/openicon), vendored in public/icons: the SVGs we
4
+ * draw and a trimmed openicon.json that keeps each entry as published,
5
+ * brand trademark notes included.
6
+ *
7
+ * On a page an icon is inline SVG in currentColor, so it takes the text colour
8
+ * and needs no extra request or CSP rule. In a terminal it is the best glyph the
9
+ * terminal can draw, by the spec's rule 6: OPENICON_GLYPHS, then NERD_FONT=1,
10
+ * then a UTF-8 locale for Unicode, else ASCII.
11
+ */
12
+ import { readdirSync, readFileSync } from 'node:fs'
13
+
14
+ type Entry = { key: string; name: string; aliases?: string[]; brand?: boolean; tui?: { nerd?: string; unicode?: string; ascii?: string } }
15
+
16
+ const DIR = new URL('../public/icons/', import.meta.url)
17
+ const set = JSON.parse(readFileSync(new URL('openicon.json', DIR), 'utf8')) as { icons: Entry[] }
18
+ const byName = new Map<string, Entry>()
19
+ for (const e of set.icons) for (const n of [e.key, ...(e.aliases ?? [])]) if (!byName.has(n)) byName.set(n, e)
20
+
21
+ const svgs = new Map<string, string>()
22
+ for (const file of readdirSync(new URL('svg/', DIR))) {
23
+ if (!file.endsWith('.svg')) continue
24
+ const raw = readFileSync(new URL(`svg/${file}`, DIR), 'utf8')
25
+ // Decorative beside a text label: no <title>, hidden from screen readers.
26
+ svgs.set(file.slice(0, -4), raw.replace(/<title>[^<]*<\/title>/, '').replace('<svg ', '<svg class="i" aria-hidden="true" focusable="false" width="1em" height="1em" '))
27
+ }
28
+
29
+ /** An icon by key or alias, as inline SVG; empty when the set has none. */
30
+ export function icon(name: string): string {
31
+ const e = byName.get(name)
32
+ return (e && svgs.get(e.key)) ?? ''
33
+ }
34
+
35
+ export const ICON_KEYS = [...svgs.keys()]
36
+
37
+ /** Our social kinds (fetch.ts) to OpenIcon keys. */
38
+ export const SOCIAL_ICONS: Record<string, string> = { x: 'x', github: 'github', linkedin: 'linkedin', discord: 'discord', youtube: 'youtube', bluesky: 'bluesky', instagram: 'instagram', tiktok: 'tiktok', reddit: 'reddit', telegram: 'telegram', producthunt: 'product-hunt', appstore: 'apple', chrome: 'chrome', playstore: 'globe' }
39
+
40
+ export type GlyphMode = 'nerd' | 'unicode' | 'ascii'
41
+
42
+ export function glyphMode(env: Record<string, string | undefined> = process.env): GlyphMode {
43
+ const own = env.OPENICON_GLYPHS
44
+ if (own === 'nerd' || own === 'unicode' || own === 'ascii') return own
45
+ if (env.NERD_FONT === '1') return 'nerd'
46
+ return /utf-?8/i.test(env.LC_ALL || env.LC_CTYPE || env.LANG || '') ? 'unicode' : 'ascii'
47
+ }
48
+
49
+ /** The best terminal glyph for an icon, falling back nerd → unicode → ascii. */
50
+ export function glyph(name: string, mode: GlyphMode = glyphMode()): string {
51
+ const t = byName.get(name)?.tui
52
+ if (!t) return ''
53
+ if (mode === 'nerd' && t.nerd) return t.nerd
54
+ if (mode !== 'ascii' && t.unicode) return t.unicode
55
+ return t.ascii ?? ''
56
+ }