first commit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
michilis
2026-08-20 22:41:25 +02:00
co-authored by Cursor
commit aa1771ea20
136 changed files with 27069 additions and 0 deletions
+115
View File
@@ -0,0 +1,115 @@
# cashumints.space configuration.
#
# Copy to .env and edit. Every value below is the built-in default, so an empty .env
# behaves exactly like no .env at all.
#
# cp .env.example .env
#
# One file at the repo root serves all three packages. It is loaded by:
# - api/ via node --env-file-if-exists=../.env (see api/package.json)
# - web/ via web/scripts/load-env.mjs, imported from astro.config.mjs
#
# Real environment variables always win over this file, so a systemd unit or a
# one-off `PORT=9000 pnpm dev:api` still overrides it. .env is gitignored.
# ─── Ports ───────────────────────────────────────────────────────────────────
# The two servers `pnpm dev` starts. Change PORT and API_URL together: the web
# build and the dev proxy reach the API at API_URL, so they must agree.
# API HTTP port.
PORT=8787
# Astro dev server / preview port.
WEB_PORT=4321
# ─── Web ─────────────────────────────────────────────────────────────────────
# Where the build and the dev proxy reach the API. A build-machine address; it is
# never written into the markup a visitor downloads.
API_URL=http://127.0.0.1:8787
# Browser-facing API origin, baked into the markup and the islands. Empty means
# same origin: icons resolve to /icons and islands fetch /api, which the dev proxy
# and nginx forward to API_URL. Deliberately does NOT fall back to API_URL — a
# visitor's browser cannot reach 127.0.0.1. Set it only when the API answers on its
# own origin, e.g. https://api.cashumints.space
PUBLIC_API_URL=
# Canonical origin for canonical links, OpenGraph tags and the sitemap.
SITE_URL=https://cashumints.space
# ─── API storage ─────────────────────────────────────────────────────────────
# Unset means api/data/, resolved from the source tree regardless of where you run
# from. Set these and they are taken as given: a relative path resolves against the
# working directory (api/ under every pnpm script), so prefer absolute paths. In
# production point both at a directory the service user owns.
# SQLite file.
# DB_PATH=/var/lib/cashumints/cashumints.db
# Cached mint icons, served at /icons/*.
# ICON_DIR=/var/lib/cashumints/icons
# ─── Nostr relays ────────────────────────────────────────────────────────────
# Three comma separated lists, all optional. Each one, unset or empty, falls back
# to the list of the same name in shared/src/nostr.ts; the values below ARE those
# defaults, spelled out so the relays this site talks to are visible in one place.
# Setting one replaces the default pool entirely — it does not add to it.
# Review and mint-announcement relays: kind 38000 and 38172. Read by the API
# indexer (probe/discovery cycles) and by the build-time "latest reviews" fetch.
# The union of the old site's read pool and its publish pool — the two differed,
# so reviews the old site published to snort/primal were invisible to it.
RELAYS=wss://relay.cashumints.space,wss://nos.lol,wss://relay.azzamo.net,wss://relay.snort.social,wss://relay.primal.net
# Profile relays for the BUILD (kind 0, prerendered reviewer names on the home
# page). A wider pool than RELAYS on purpose: relay.cashumints.space holds no kind
# 0 at all and snort/primal hold almost none, so the two aggregators below are what
# turn npubs into names. Read the PROFILE_RELAYS comment in shared/src/nostr.ts
# before trimming this — relay.nostr.band was measured and deliberately excluded.
PROFILE_RELAYS=wss://relay.cashumints.space,wss://nos.lol,wss://relay.azzamo.net,wss://relay.snort.social,wss://relay.primal.net,wss://purplepag.es,wss://relay.nostr.net
# Profile relays for the BROWSER: the same kind 0 lookup, done client side for
# pubkeys the build did not resolve. PUBLIC_ means it is baked into the shipped
# markup and a visitor's browser connects to these directly, so list only relays
# that accept public websocket connections. Normally kept equal to PROFILE_RELAYS.
PUBLIC_PROFILE_RELAYS=wss://relay.cashumints.space,wss://nos.lol,wss://relay.azzamo.net,wss://relay.snort.social,wss://relay.primal.net,wss://purplepag.es,wss://relay.nostr.net
# Review relays for the BROWSER: where the reviews panel reads from and where a
# newly signed review is published. Defaults to RELAYS above when unset, which is
# what production wants. Point it at a local relay to exercise the publish path in
# development without putting test reviews on the public network.
# PUBLIC_REVIEW_RELAYS=
# Relays used to reach a NIP-46 remote signer (the "Remote signer" and "Primal"
# login routes). Not review relays: these carry small encrypted RPC between this
# browser and the reader's signer app, so they have to be relays both ends can
# reach. Defaults to relay.nsec.app and relay.primal.net when unset.
# PUBLIC_CONNECT_RELAYS=
# ─── Indexer timing ──────────────────────────────────────────────────────────
# All are positive integers; anything unparseable falls back to the default.
# Minutes between probe cycles (every mint's /v1/info).
PROBE_INTERVAL_MIN=10
# Minutes between discovery cycles (relay scan for new mints and reviews).
DISCOVERY_INTERVAL_MIN=60
# Mints probed in parallel.
PROBE_CONCURRENCY=8
# Per-mint request timeout, milliseconds.
PROBE_TIMEOUT_MS=5000
# ─── Ranking ─────────────────────────────────────────────────────────────────
# Bayesian prior mean C. The neutral 3 is intentional — read the "Ranking" section
# of README.md before changing it. `global` uses the observed global mean instead,
# which is the literal BACKEND.md behaviour and ranks noticeably worse.
SCORE_PRIOR_MEAN=3
# ─── Tooling ─────────────────────────────────────────────────────────────────
# Dev server Boneyard captures against (`pnpm bones`). Defaults to WEB_PORT.
# BONES_URL=http://localhost:4321
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
.astro/
api/data/
*.log
.DS_Store
.env
+186
View File
@@ -0,0 +1,186 @@
# NOTES-PAGES.md: page audit
An audit of every internal link in `web/src` against every route `astro build` actually
emits, plus a crawl of the built `dist/` for links that resolve to nothing and anchors
that point at ids no page has. Findings first, then what was built to close them.
Audit run against 58 indexed mints, API up. Re-run it with:
```bash
pnpm build && node web/scripts/check-links.mjs web/dist
```
`LIST_TARGETS=1` on that command prints every distinct internal target and which pages
link to it. The checker exits non-zero on a problem, so it can gate a deploy.
## 1. Findings
Routes are listed once each. "Linked from" is where the link lives in the source, not
every page that renders the component.
| Route | Linked from | Existed before | Action |
|---|---|---|---|
| `/` | Topbar brand, 404 | yes | none |
| `/mints` | Topbar nav, Footer, home hero + CTA + section links, mint page breadcrumb, 404 | yes | none |
| `/mints?sort=reviews` `?sort=rating` `?sort=recent` | home quick filters | yes (handled by the island) | none |
| `/mints?nut=17` | home quick filter "Supports WebSockets" | route yes, filter **no** | **not fixed**, see below |
| `/mints#mint-search` | Topbar search affordance | id exists, link never renders | **not fixed**, see below |
| `/mint/[host]` | MintCard, home review cards | yes, 58 prerendered | none |
| `/about` | Topbar nav, Footer, home CTA | yes | none |
| `/about#nip87` | home "Signed, not submitted" card | yes | none |
| `/about#nuts` | mint page NUT panel | yes | none |
| `/wallets` | Topbar nav, Footer | yes | none |
| `/#latest-reviews` | Topbar nav "Reviews" | anchor existed | **changed**: nav now points at `/reviews` |
| `/reviews` | nothing linked to it | **no** | **built** |
| `/terms` | nothing linked to it | **no** | **built**, linked from the footer |
| `/privacy` | nothing linked to it | **no** | **built**, linked from the footer |
| `/disclaimer` | nothing linked to it | **no** | **built**, linked from the footer and every mint page |
| `/404` | (served by nginx on a miss) | yes | **reworked**, see below |
| `/sitemap.xml` | nothing | **no** | **built** |
| `/robots.txt` | nothing | **no** | **built** |
| `/api/*`, `/icons/*` | mint icons, islands | not in `dist` by design | none: nginx proxies both to the API |
The link crawl over the built site found **zero** broken internal links and **zero**
dead anchors, before and after. Nothing pointed at the missing pages, which is exactly
why they were easy to miss: the gap was between the page inventory and the navigation,
not inside the navigation.
### Findings not closed by this task
**`/mints?nut=17` does nothing.** The home page offers "Supports WebSockets" as a quick
filter and links to `/mints?nut=17`. The `/mints` island reads `q` and `sort` from the
query string and ignores everything else, so the link lands on an unfiltered list. The
fix is not one line: `MintListItem` (the payload `/api/mints` returns and the only data
`/mints` has) carries no `nuts` array, so `MintCard` has nothing to put in a
`data-nuts` attribute. Either the API adds `nuts` to the list payload, or `/mints`
fetches all 58 mint details at build time the way `/mint/[host]` already does. Worth
doing, out of scope for a page audit.
**`/mints#mint-search` never renders.** `Topbar` shows a "Search N mints" affordance
only when a `mintCount` prop is passed. Nothing passes a number: the mint page passes
`mintCount={undefined}` explicitly and no other page passes it at all, so the anchor
is never in the emitted HTML. The knock-on is in `Base.astro`: the `/` key handler
falls back to "no search box on this page, so follow the topbar search link", and that
branch is unreachable, so `/` does nothing on the mint pages, `/about`, `/wallets` and
the three new legal pages. Either pass the real count from `/mints` and the mint pages,
or drop the affordance and the fallback branch.
**Islands and the view transitions router.** `Base.astro` now renders `<ClientRouter />`
(added by concurrent work on the site, not by this task). Under it, a same-site
navigation swaps the document without re-running page scripts, and no island on the
site re-initialises on `astro:page-load`: `/mints` search and sort, the mint page
reviews panel, the pulse ticker and the new `/reviews` feed all run once, on the first
full page load. Every island needs the same `astro:page-load` treatment the reveal
script in `Base.astro` already has. Flagged rather than fixed here, because it is one
cross-cutting change across every island and it belongs with the router work.
## 2. What was built, and what was already there
### Built
- **`/reviews`** (`web/src/pages/reviews.astro`). The global feed: every mint's reviews
in one list, newest first.
- Build time: the 30 most recent written reviews are read from the relays and
prerendered, so the page has real content for a crawler and for a reader with
JavaScript off. The controls hide themselves through a `<noscript>` rule rather
than sitting there doing nothing.
- Runtime: the island re-queries the relays for kind 38000 with no mint filter and
resolves each event back to a known mint, then paginates 20 items to a page.
Measured against the live relays: 644 events, 156 resolving to indexed mints across
25 mints.
- Filters: All, 5 star, Critical, each with a live count, plus a mint filter. The
mint filter is a text input backed by a `<datalist>` of every mint, so typing part
of a name or a domain narrows it; 58 options is too many to scroll. `?mint=` in the
URL preselects one.
- Cards are the mint page's review cards, literally: `web/src/lib/review-cards.ts`
was extracted out of the reviews island so both render from the same functions.
Same identity resolution (kind 0 name, NIP-05, generated avatar, "Anon" fallback),
same copy-to-clipboard npub, same provenance note. Each card links to its mint.
- Same rating-only rule, applied per day: a review with a body is a card, and every
rating with no comment from the same day collapses into that day's single summary
line, which sits in the feed at that day's newest rating. Most review events on the
network are ratings with no comment (586 of 644 in the measured run), so without
this the feed would be nothing else. An opened summary stays open across filter and
page changes.
- **`/terms`**, **`/privacy`**, **`/disclaimer`**. Prose in `.prose` at 720px, h1 plus
h2 sections, sentence case, no em dashes, dated. The disclaimer is linked from the
site footer **and** from the bottom of every mint page, which is where the decision
it is about actually gets made.
- **`/sitemap.xml`** (`web/src/pages/sitemap.xml.ts`). Generated at build: 8 static
pages plus one entry per prerendered mint page, 66 URLs in the audit run. Written by
hand rather than with `@astrojs/sitemap` because a mint page has a real `lastmod`
available (the newest of `last_review_at` and `last_online`) and the integration
would stamp every URL with the build time instead.
- **`/robots.txt`** (`web/src/pages/robots.txt.ts`). Allows everything and names the
sitemap. Generated rather than dropped in `public/` so the `Sitemap:` line follows
`SITE_URL` instead of hardcoding the production host.
- **`web/scripts/check-links.mjs`**. The link checker this audit ran on.
### Changed
- **`/404`**. It already existed, was styled, and already held the mint fallback (on a
`/mint/{host}` miss it asks the API and renders a summary behind the page level
skeleton). It was missing three things the inventory asks for: the pixel moai, the
"This page does not exist" heading, and a search input. All three added; the fallback
is untouched and still runs before anything says the page is missing. The moai is
44px, because DESIGN.md says it is never scaled large.
- **`Topbar`**: "Reviews" pointed at `/#latest-reviews`, a section of the home page. It
now points at `/reviews`.
- **`Footer`**: gained a second row with Disclaimer, Terms and Privacy, an "All
reviews" link, and the one line worth repeating on every page ("mints are custodial,
a mint can disappear with your money").
- **Home page**: the "Latest reviews" strip's section link said "All mints →" and went
to `/mints`. It now says "All reviews →" and goes to `/reviews`.
- **`web/src/components/Reviews.astro`**: the card builders moved out to
`lib/review-cards.ts`. Behaviour is unchanged, verified in a browser: same cards,
same counts, same filters, same collapsed summary.
- **`shared/src/nostr.ts`**: `WRITTEN_MIN_CHARS` moved here, since the build time feed
and both browser islands now all need the same answer to "is this a written review".
### Already present, verified rather than touched
- **`/wallets`** is real content, not a stub: eight wallets with platforms and a
one-line description each, a card grid on the shared design system, and a closing
note that the list is not an endorsement. Worth recording why it looks curated rather
than ported: per `NOTES.md`, the old React site had no wallet directory to port, only
a header link to `docs.cashu.space/wallets`.
- **`/about`** has every section FRONTEND.md specifies, with the anchor ids the rest of
the site links to: `#cashu`, `#mints`, `#nip87`, `#how`, `#relays`, `#nuts`,
`#numbers`, `#source`.
- **`/mint/[host]`** prerenders all 58 mints and the client fallback handles unknown
hosts.
## 3. For the operator, before this goes live
Two things in the new pages depend on how the site is deployed, and this repo cannot
know either. Both are written conditionally and one is marked on the page itself.
1. **Log retention** (`/privacy`, "Server logs"). The page says the web server keeps
standard access logs with IP, time, request, status, user agent and referrer, which
is true of any default nginx. It carries a visible **operator note** saying the
retention figure needs confirming: the nginx config in `README.md` does not set
`access_log` at all, so the real answer is your distribution's default (on Debian
and Ubuntu, logrotate daily keeping 14 days). Confirm what your server actually
does, write the number in, and delete the note and the `.operator-note` style.
2. **Contact route** (`/terms`, "Contact"). It points takedown requests at the GitHub
repository and names Azzamo as the operator. If there should be an email address or
a Nostr contact instead, that is the paragraph to change.
Neither page invents a legal entity, a jurisdiction, a governing law clause or a
company name, because none of those are knowable from the code. If the site needs them,
they have to come from whoever runs it.
## 4. Verification
- `pnpm build`: 67 pages (58 mints, 7 static pages, 404) plus `sitemap.xml` and
`robots.txt`.
- `pnpm typecheck`: 0 errors.
- `node web/scripts/check-links.mjs web/dist`: 67 pages, 97 distinct internal targets,
no broken internal links, no dead anchors.
- Headless browser over `astro preview`: `/reviews` loads 156 live reviews, the rating
filters and the mint filter both narrow the list and the counts, pagination works,
day summaries open and stay open across a re-render, and a card's mint link resolves
(`/mint/mint.belgianbitcoinembassy.org` → 200). With JavaScript off, 30 prerendered
cards render and the controls are hidden. The mint page reviews panel is unchanged
after the extraction. `/mint/does-not-exist.example.com` still runs the API lookup
and then shows the moai, the heading and the search box.
+215
View File
@@ -0,0 +1,215 @@
# NOTES.md: what the old codebase actually does
Findings from reading `cashu-mint-page/` (the previous cashumints.space frontend, React + Vite + NDK).
Everything here is copied from the source, not from memory. File references are to the old repo.
## Nostr: event kinds
From `src/utils/ndk.ts`:
```ts
export const MINT_RECOMMENDATION_KIND = 38000; // NIP-87: mint recommendation/review
export const CASHU_MINT_KIND = 38172; // NIP-87: Cashu mint announcement
```
- **38172**: mint announcement. Mint URL lives in the `u` tag. This is the only source of mint
discovery in the old site: there is no hardcoded mint list anywhere in the repo
(`src/hooks/useAllMints.ts` subscribes to `{kinds:[38172], limit:1000}` and builds the whole
directory from what comes back).
- **38000**: review / recommendation event.
## Nostr: relays
From `src/utils/ndk.ts`, `CASHU_RELAY_POOL` (this is the pool used for reads):
```
wss://relay.damus.io
wss://nos.lol
wss://relay.azzamo.net
wss://relay.cashumints.space
```
`PROFILE_RELAYS` is the same list (with `nos.lol` and `relay.azzamo.net` duplicated, a bug that
does nothing).
From `src/services/reviewPublisher.ts`, `RELAY_URLS` (the publish pool, a **different** list):
```
wss://relay.cashumints.space
wss://relay.damus.io
wss://relay.snort.social
wss://relay.primal.net
```
Note the asymmetry: the old site publishes to `snort.social` and `primal.net` but never reads from
them, so some of its own published reviews were invisible to it. The new backend reads the union of
both lists (6 distinct relays) so nothing published by the old site is lost.
Also note `RELAY_URLS[0]` (`wss://relay.cashumints.space`) is used as the relay hint in the `a` tag.
## Nostr: review event wire format
Two code paths build events, and they disagree. What actually ships is `publishReview()`:
`src/services/reviewPublisher.ts` `publishReview()` (the one called by the live flow):
```ts
event.kind = 38000;
event.content = `[${rating}/5] ${content}`;
event.tags = [
['k', '38172'], // kind being recommended
['u', mintInfo.url, 'cashu'], // mint URL, with 'cashu' as third element
['d', mintInfo.pubkey] // mint's own pubkey from /v1/info (NIP-33 d tag)
];
```
`createReviewEvent()` in the same file builds a richer event that is **never published** (the flow
calls `createReviewEvent` and then throws the result away, rebuilding the event inside
`publishReview`). The dead version adds:
```ts
['a', `38172:${mint.pubkey}:${mint.pubkey}`, 'wss://relay.cashumints.space'],
['rating', String(rating)]
```
**Consequence for reading:** most reviews in the wild have NO `rating` tag. The rating is encoded in
the content as a `[N/5]` prefix. Any reader that only looks at the `rating` tag will see nothing.
### Rating parse order (from `src/utils/reviewHelpers.ts` `parseNIP87Review`)
1. `rating` tag, if present and 1..5.
2. Else `^\s*\[([1-5])\/5\]` at the start of content.
3. Else the loose regex `rating[:\s]*([1-5])|([1-5])[\/]5|([1-5])\s*star` (case insensitive).
4. Else **default to 5**.
Step 4 is the reason every mint on the old site shows 5/5. Anything unparseable silently becomes a
five star review. The new indexer stores `rating = NULL` instead and excludes NULLs from averages
(the schema in BACKEND.md already allows this).
### Review filters used for reading (`src/hooks/useReviews.ts`)
Two filters, OR'd:
```ts
// NIP-87 proper, only when the mint's /v1/info pubkey is known
{ kinds:[38000], '#d':[mintPubkey], '#k':['38172'], limit }
// legacy / URL based, always included
{ kinds:[38000], '#u':[ mintUrl, noTrailingSlash, withTrailingSlash,
noProtocol, httpsForm, httpForm ], limit }
```
So a review is matched to a mint by `d`=mint pubkey **or** `u`=mint URL in any of 6 spellings.
The new indexer resolves both the same way, but normalizes the `u` value instead of enumerating
spellings.
### Dedupe
`aggregateReviews()` keys by **pubkey**, keeping the newest event per author (treating 38000 as a
NIP-33 replaceable event, which it is: 30000-39999 is the addressable range). One review per npub
per mint. The new backend keeps every event row (keyed by event id, per BACKEND.md schema) but
applies the same newest-per-(pubkey, mint) rule when computing counts and averages.
## Mint URL normalization in the old code
There is no single normalizer. Three different ad-hoc ones:
1. `src/services/api.ts` `getMintInfoByDomain` / `getMintPubkey`:
`url.replace(/^https?:\/\//,'').replace(/\/+$/,'')` then `https://${clean}/v1/info`.
Forces https, strips protocol and trailing slashes, **keeps the path**. This one is fine.
2. `src/hooks/useAllMints.ts` and `usePopularMints.ts`: `new URL(mintUrl).hostname`, then
`.replace(/^mint\./,'').replace(/^www\./,'')` for the display name. The **hostname only**, so the
path is dropped. `https://mint.minibits.cash/Bitcoin` and `https://mint.minibits.cash/Other`
collapse to the same display name, and the map is keyed on the raw un-normalized `u` tag value,
so `https://x.com` and `https://x.com/` are two separate mints in the directory.
3. `src/utils/reviewHelpers.ts` `isReviewForThisMint`: lowercases, strips protocol and trailing
slashes, then compares **domain only** (`split('/')[0]`), with a `www.` variant fallback.
Explicit comment says subdomain and content matching were removed as too false-positive prone.
Net effect: mints are deduped by raw tag string (too strict, splits on trailing slash) while reviews
are matched by bare domain (too loose, a review of `mint.minibits.cash/Bitcoin` counts for
`mint.minibits.cash/Anything`). The new backend fixes both directions: one normalizer, path is part
of identity, applied to mints and reviews alike (see `api/src/normalize.ts`).
## Old scoring
`src/hooks/usePopularMints.ts`:
```ts
.filter(m => m.reviewCount >= 3)
.sort((a,b) => b.averageRating !== a.averageRating
? b.averageRating - a.averageRating
: b.reviewCount - a.reviewCount)
.slice(0, limit)
```
Plain mean rating, descending, review count only as a tiebreak, with a hard `>= 3 reviews` floor.
Combined with the "default to 5" parse fallback, this guarantees the leaderboard is a wall of 5.0s
ordered arbitrarily. Replaced with the Bayesian weighted score from BACKEND.md.
**Worth keeping from the old scoring** (carried into the new implementation):
- **Dedupe by pubkey before averaging.** One npub, one vote per mint. Without this a single author
can move a mint's average by replaying reviews. The Bayesian formula does not protect against
this on its own, so the new backend keeps it.
- **The `reviewCount >= 3` floor**, but repurposed. As a ranking rule it is too blunt (it hides
new mints entirely). The prior weight `m = 5` in the Bayesian formula does the same job
continuously, so the floor is dropped from ranking. It survives as a display rule only: the home
page "top mints" grid shows the top 6 by score, and a mint with 1 review simply cannot out-score
an established one, so no explicit floor is needed.
- **The 30 day `since` window as a second filter alongside the unbounded one.** The old code fires
`{kinds:[38000], limit:100}` and `{kinds:[38000], limit:100, since: now-30d}` together, which
works around relays that return an arbitrary slice for an unbounded query. The discovery loop does
the same thing (broad backfill plus a recent window) for the same reason.
Not kept: the `filter(m => m.reviewCount >= 3)` on the directory itself, the mean-only sort, and the
default-to-5 rating.
## The rug bug, precisely
`src/pages/MintPage.tsx`:
```ts
if (error) return <ErrorDisplay message={error} retryFn={fetchMintInfo} />; // line 105
if (!mintInfo) return <ErrorDisplay message="No mint information available" .../>; // line 106
```
`fetchMintInfo` calls `getMintInfoByDomain`, which is a direct browser `axios.get` to
`https://{host}/v1/info` with a 10s timeout. If the mint is down, blocks CORS, or serves mixed
content, `error` is set and the component returns before rendering the header, the reviews panel, or
the review form. So a rugged mint, the one people most need to warn each other about, has no
reachable review page. There is no cache: every page view re-fetches from the mint itself.
This is why the new architecture puts a server-side cache in front (`mints.info_json`, never
overwritten on a failed probe) and renders the mint page from that cache, not from a live fetch.
## Other things found
- **No hardcoded mint list.** `public/sitemap.xml` has 5 static routes and no mint pages.
`src/store/mintStore.ts` is an empty runtime cache. So the seed list in `api/src/seed.ts` had to
be assembled from the mints the old site actually surfaces via Nostr, listed there with a comment.
- **No wallet directory.** FRONTEND.md says to port wallet content from the old project, but the
only thing there is an external link to `https://docs.cashu.space/wallets` in `Header.tsx:57` and
`MobileMenu.tsx:65`. There is no wallet data to port. `/wallets` is built from a curated list
instead, flagged in the summary.
- **Routing was `/{host+path}`**, e.g. `/mint.minibits.cash/Bitcoin`, via a catch-all
`<Route path="/*" element={<MintPage/>}/>`. The new site uses `/mint/{slug}` with the slug rules
from BACKEND.md, and redirects are not attempted (old URLs were never indexed: no SEO, see
PROJECT.md).
- **`src/utils/nostr.ts` `hexToNpub` is fake.** It returns `` `npub${hex.slice(0,8)}...` ``, which is
not bech32 and not a real npub, it just looks like one. The mockup's `npub7b2bfe92…` strings are
this bug rendered. The new site encodes real npubs with `nip19.npubEncode` from nostr-tools.
- **`src/utils/nutInfo.ts`** has human readable NUT names and descriptions, but several are wrong
(NUT-04 is titled "Token Spending", NUT-05 "Token Melt", NUT-07 "Swap", NUT-08 "Melt"). The
correct names are used instead, matching the mint page mockup's five highlighted rows.
- Old site had `RelayPoolTest.tsx` and a lot of `console.log` debug output shipped to production,
plus `loadDemoReviews()` returning fake Alice/Bob/Charlie reviews. None of that is carried over.
## Library choice
The old project depends on **both** `@nostr-dev-kit/ndk` and `nostr-tools`. NDK is used for all
relay work; `nostr-tools` is a transitive dependency it never directly imports. NDK's browser bias
(it assumes a persistent process and has no clean shutdown, see the `cleanupSharedNDK` comment
"NDK doesn't have a disconnect method") makes it a poor fit for a Node indexer that must exit
cleanly. The new code uses **nostr-tools** on both sides: `SimplePool` in the indexer, and the same
library in the browser island, so there is one Nostr code path and one set of parse rules shared
through `shared/`.
+680
View File
@@ -0,0 +1,680 @@
# cashumints.space
A Cashu mint explorer and review site. Lists every mint discoverable on the Nostr network,
shows each mint's live metadata from its own `/v1/info`, and surfaces community reviews
published as NIP-87 events.
Made by [Azzamo](https://azzamo.net).
```
Nostr relays Cashu mints
(NIP-87 events) (/v1/info)
| |
v discovery (hourly) v probe (10 min)
+----------------------------------------------+
| api/ Node + Hono + SQLite |
| last-known-good metadata, 4 REST endpoints |
+----------------------------------------------+
| |
build-time fetch runtime fetch (islands)
v v
+----------------------------------------------+
| web/ Astro, static, prerendered |
+----------------------------------------------+
|
browser <-> relays (review text, signing via NIP-07 or NIP-46)
```
The backend is a memory layer, not an authority. Its job is to remember what a mint looked
like when it was last reachable, so an offline mint still has a page people can read and
review. Review content is never stored server-side.
Identity is the same story: logging in is a browser-side affair the API never sees. A
session is a pubkey plus, for a remote signer, the NIP-46 connection details, kept in
localStorage. No private key is ever asked for, accepted or stored, by any code path in
this repository.
## Layout
| Path | What it is |
| --------- | -------------------------------------------------------------- |
| `api/` | Indexer and REST API. Hono, better-sqlite3, nostr-tools. |
| `web/` | Astro static site with vanilla TypeScript islands. |
| `shared/` | Types, NIP-87 constants, URL normalization, scoring, NUT names. |
The site is in English, Spanish and Dutch. `web/src/i18n/` holds the message catalogs
and the locale table; `web/src/i18n/GLOSSARY.md` holds the term decisions a translator
needs before touching either. See [Languages](#languages).
`NOTES.md` records what was found in the previous codebase: event kinds, tags, relays, the
rating encoding, and the bugs this rebuild fixes.
## Requirements
- Node 20 or newer (uses `--experimental-strip-types`, so no build step for the API)
- pnpm 9 or newer
## Setup
```bash
pnpm install
```
```bash
pnpm --filter ./shared build
```
`shared/` compiles to `dist/`, which both other packages import. Run it once after install
and again whenever you change something in `shared/src`.
## Seed the database
Ingests the initial mint list, probes every mint, runs a full discovery backfill against
the relays, and prints a summary table. Takes about a minute against the live network.
```bash
pnpm seed
```
## Development
Runs the API on `:8787` and the Astro dev server on `:4321`. Both ports come from
`.env` — see [Configuration](#configuration).
```bash
pnpm dev
```
Run them separately if you prefer:
```bash
pnpm dev:api
```
```bash
pnpm dev:web
```
The web app reads the API at build time, so the API must be running before you build or
start the dev server.
## Skeleton loading
Two regions fetch their content at runtime and get a skeleton while they wait: the reviews
panel body on a mint page (reviews come from Nostr relays) and the prerender miss summary
on `/mint/{host}` for a mint added since the last build. Nothing prerendered is ever
covered by a skeleton, so the mint header, verdict strip, sidebar, the reviews panel's own
header and filter counts, the pulse ticker and every grid stay on screen as they are.
The bones are captured from the real rendered components by
[Boneyard](https://github.com/0xGF/boneyard) and committed as
`web/src/bones/*.bones.json`, so they ship with the island and draw immediately without a
network round trip.
Regenerate them with the dev server running:
```bash
pnpm bones
```
That visits a real mint page and a deliberately unprerendered `/mint/` URL at 360, 768 and
1280px wide, and rewrites both bone files. Both islands render fixture content
(`web/src/lib/skeleton-fixtures.ts`) while the capture browser is looking, so the result
does not depend on a relay answering. The fixtures load only during a capture and are not
part of the bundle a visitor downloads.
**Re-run `pnpm bones` after changing the layout of a review card or of the prerender miss
summary**, including CSS-only changes. Stale bones are not a build error, they just stop
matching the content that replaces them.
Capturing needs a Chromium for Playwright, once per machine:
```bash
pnpm --filter ./web exec playwright install --with-deps chromium
```
## Static assets
`web/public/` ships as-is: the fonts, the social card and the icon set.
**Fonts** are self-hosted from `web/src/assets/fonts/` — Space Grotesk, Inter and
JetBrains Mono, as the `latin` and `latin-ext` woff2 subsets Google Fonts serves, with
the same `unicode-range` declarations, so rendering is identical to the CDN it replaced.
All three are variable fonts, so six files cover all fourteen faces. The site makes no
third-party request for them, which is both one less thing in the critical path and one
less entry in the list on `/privacy`. They are SIL Open Font License 1.1; the notice
ships at `/fonts/OFL.txt`.
They sit in `src/assets/` rather than `public/` so the build fingerprints them into
`/_astro/`, which is what makes `Cache-Control: immutable` honest and puts them under a
cache rule the nginx config already has. Unhashed and uncached they are re-fetched on
every client-side navigation — the router re-inserts their preload links on each swap —
and the typefaces visibly reload from page to page.
**The social card and icons** (`og.png`, `favicon.ico`, `favicon.svg`,
`apple-touch-icon.png`, `icon-192.png`, `icon-512.png`) are generated once and committed:
```bash
node web/scripts/make-assets.mjs
```
That draws the 1200x630 card in a headless Chromium using the site's own fonts and
tokens, then downsamples one icon master into the rest. Re-run it after a brand change.
It is deliberately not part of `pnpm build`: the site has to build on a machine with no
browser, and these files change roughly never. It needs the same Chromium `pnpm bones`
does.
## Tests
Unit checks over URL normalization, rating parsing, scoring and the review-dedupe SQL:
```bash
pnpm --filter ./api test
```
The warning banners. Fixture-driven checks over `getMintWarnings`, the one function that
decides whether a mint page shows "melt only", "withdrawals disabled", "mint frozen" or
one of the three offline tiers. Fixtures are real `/v1/info` payloads (or a real one with
a single flag flipped, noted in the file) under `shared/fixtures/warnings`:
```bash
pnpm --filter ./api test:warnings
```
The offline acceptance check. Copies the seeded database, points a healthy mint at a dead
URL, probes it until it is marked offline, and asserts the mint page still serves its full
cached metadata and reviews:
```bash
pnpm --filter ./api test:offline
```
Type checking across the workspace:
```bash
pnpm typecheck
```
## Build the site
```bash
pnpm build
```
Output lands in `web/dist/`. Every page is prerendered once per language, so ~55 mints
and 9 static routes come out as ~200 pages, each with real titles, meta descriptions,
OpenGraph and Twitter tags, a social card, a self-referencing canonical, a full hreflang
set and a JSON-LD graph. `sitemap.xml` lists every indexable one with its `xhtml:link`
alternates, and `robots.txt` sits beside it.
### What is indexed, and what is not
One rule, in `web/src/lib/seo.ts`, decides it: a mint page is left out of the index when
the site has **never once reached** that mint **and** nobody has reviewed it. Such a page
has no name, no description, no version and no reviews, because all of those come from a
mint that answered or a person who wrote something — so every one of them is the same
page as the next. It stays listed on `/mints`, stays linked, stays searchable on the site
and stays reviewable; it just carries `noindex, follow` and is absent from the sitemap,
until the mint answers once or someone reviews it.
The mint page and the sitemap import that one predicate rather than each testing for it,
and `pnpm check:hreflang` verifies from the built output that they still agree — a page
saying `noindex` while the sitemap advertises it is a contradiction, and it fails the
build.
Structured data is assembled in `web/src/layouts/Base.astro` and nowhere else, from the
builders in `web/src/lib/schema.ts`, so a document cannot end up with two `ld+json`
blocks or two conflicting `@id`s. Every page carries `Organization`, `WebSite` and
`WebPage`; mint pages add `BreadcrumbList` and a `Service` node whose `aggregateRating`
appears only when reviews actually carried ratings; `/mints` and `/wallets` add an
`ItemList`. Mint names and descriptions are written by mint operators, so
`serializeGraph` escapes them for the one context that matters — nothing that could
close or reopen a script element survives it.
Check the build for broken internal links, links to routes that were never emitted, and
anchors pointing at ids no page has:
```bash
pnpm check:links
```
It exits non-zero on a problem, so it can gate a deploy. `LIST_TARGETS=1` prints every
internal target and which pages link to it. `NOTES-PAGES.md` holds the last full audit.
Check the i18n and indexing side of the build: `<html lang>` against the URL's locale, a
self-referencing canonical on every page, a complete and reciprocal hreflang set
including `x-default`, and a sitemap that lists every indexable page with its alternates
and no page that asked not to be indexed:
```bash
pnpm check:hreflang
```
The catalogs are checked before every build, and separately with:
```bash
pnpm check:i18n
```
Both gate a deploy. See [Languages](#languages) for what they look for.
## Languages
English, Spanish and Dutch. English is the site as it was; the other two are the same
site, prerendered again.
### Routing
Path-prefix locales, with English at the root:
| Language | Home | A mint page |
| ---------- | ------- | ------------------------ |
| English | `/` | `/mint/kashu.me` |
| Spanish | `/es` | `/es/mint/kashu.me` |
| Dutch | `/nl` | `/nl/mint/kashu.me` |
Every existing English URL stays exactly where it was, which matters more here than
tidiness: those URLs are indexed, linked, and pasted into wallets.
**Route slugs stay English in every language**: `/es/mints`, never `/es/mentas`. This is
a deliberate trade. Translated slugs read marginally better in an address bar and cost a
permanent, growing table of slug-per-route-per-language that every internal link, the
sitemap, the link checker and the switcher have to agree on forever. Stable URLs are
worth more here, and the hreflang set is what actually tells a crawler these are the same
page.
**Nothing redirects by language.** No `Accept-Language` sniffing, no IP geolocation. A
crawler asking for `/mint/kashu.me` gets `/mint/kashu.me`, and a reader who deliberately
opened the English page is not overruled by a browser setting they configured years ago.
The language control in the header is how you change language, and it lands on the page
you were already reading.
One file per route emits all three: pages live under `web/src/pages/[...locale]/`, and
`localePaths()` in `web/src/i18n/paths.ts` is their `getStaticPaths`. There is one copy
of each page's markup, translated by `t()`, rather than three copies to keep in step.
### What is and is not translated
Translated: every piece of site copy. Navigation, buttons, labels, filters, empty
states, error states, form copy, tooltips, `aria-label`s, the static pages, the warning
banners, page titles and meta descriptions, and the plain-language name beside a NUT
number.
Never translated, because it is data rather than copy:
- review text, and the name, NIP-05 and npub of whoever wrote it
- mint names, mint descriptions and MOTDs, in whatever language the operator wrote them
- URLs, hosts, npubs, pubkeys, version strings
- protocol terms: `NUT-04`, `NIP-87`, `bunker://`, Nostr, Lightning, ecash, sat
A mint's own `description` is the first sentence of its page's meta description, verbatim.
Only when a mint has published none does the site write that sentence itself.
### Adding a fourth language
Four steps, and the build tells you if you miss one.
1. **`web/src/i18n/GLOSSARY.md`** — add the column and decide the terms first. This is
the step people skip, and it is the one that costs later: a reader who meets two words
for "review" has to work out whether they are the same thing.
2. **`web/src/i18n/xx.json`** — copy `en.json` and translate it. Flat, dotted keys.
A key with a count uses `.one` / `.other`; a language with more plural categories may
add `.few`, `.many`, `.zero`, and `Intl.PluralRules` picks between them. Nothing else
in the codebase has to know.
3. **`web/src/i18n/config.ts`** — add the entry to `LOCALES`:
```ts
{ code: 'pt', label: 'Português', intl: 'pt-BR', og: 'pt_BR' },
```
`label` is the language's own name for itself, and is what the switcher shows. `intl`
is the tag `Intl` gets, region included, because number and date formatting differ by
region even when the language does not.
4. **`web/src/i18n/locales.mjs`** — add the code to `LOCALE_CODES`. This is the same list
in plain JavaScript, for `astro.config.mjs` and the checker, both of which run before
TypeScript exists. `check-i18n` compares the two lists and fails if they disagree, so
forgetting this is a build error rather than a mystery.
That is the whole change. The Astro i18n config, the routes, the switcher, the hreflang
sets, the `og:locale:alternate` list and the sitemap all read from `LOCALES` and pick the
new language up on the next build.
Two things you do not have to do: dates, times and numbers come from `Intl` and are right
for the new locale immediately, and relative times fall back to
`Intl.RelativeTimeFormat` for any unit the new catalog does not tighten with its own
`time.ago.*` string.
### What the build checks
`pnpm check:i18n` runs before every build (from `astro.config.mjs`) and on its own. Four
questions:
| | | |
| --- | --- | --- |
| `missing` | a key English has and this locale does not | **warning**, listed by name |
| `unknown` | a key a locale has and English does not | error |
| `undefined` | a `t('...')` in the source no catalog defines | error |
| `client` | an island reaching for a key outside `CLIENT_NAMESPACES` | error |
Missing keys are a warning on purpose: they fall back to English, and half a language is
better than no language. Everything else is an error, because each one puts a wrong
string in front of a reader. A key that resolves nowhere at all renders as humanised
words rather than as `mint.warnings.meltOnly`, so even the unreachable case is not a raw
key on screen.
It also diffs the warning-banner copy against `shared/src/warnings.ts`. The decision
about which banner a mint gets lives in `shared/` and has to stay language-free, so the
English sentences live there too (the API's fixture tests assert on them) and the
catalogs carry the same keys for translation. Two copies of safety copy is exactly the
sort of thing that drifts, so the two are compared on every build.
`pnpm check:hreflang` runs over `dist/` after a build: `<html lang>` against the URL,
self-referencing canonicals, complete and reciprocal alternate sets, `x-default` pointing
at English, every alternate resolving to a page that was actually emitted, and a sitemap
entry with alternates for each. The 404 pages are the one exception and are checked for
the opposite: no canonical, no alternates, and a `noindex`.
### How the strings reach an island
The prerendered half of the site is translated at build time and costs a visitor
nothing. The islands need their strings in the browser, and the requirement is that a
Spanish page ships Spanish and nothing else.
`Base.astro` inlines the current locale's client-facing keys as one
`<script type="application/json" data-i18n>` in the body, already resolved against
English. Islands read it through `useI18n()` in `web/src/i18n/client.ts`.
The alternative, importing `es.json` from an island, does not work here: island chunks
are shared across every locale, so anything imported into one is downloaded by all of
them. A dynamic `import()` keyed on locale would avoid that but costs a round trip on the
critical path of every island and leaves all three catalogs sitting in `dist/_astro/`.
Inlining travels in HTML the page was sending anyway.
It is in the body rather than the head because the view transition router replaces the
body on every navigation. An island reading it after a swap reads the page it is actually
on; a head script would be kept and would keep answering in the language the reader
arrived in.
`CLIENT_NAMESPACES` in `config.ts` decides which namespaces are inlined, and
`check-i18n` fails the build if an island reaches outside that list.
## Configuration
Every variable has a working default, so nothing has to be set to run the site locally.
To change any of them, copy the example file and edit it:
```bash
cp .env.example .env
```
One `.env` at the repo root serves all three packages. The API loads it through node's
own `--env-file-if-exists`, and the web side through `web/scripts/load-env.mjs`, imported
at the top of `astro.config.mjs`. In both, a real environment variable wins over the file,
so a systemd `Environment=` line or a one-off `PORT=9000 pnpm dev:api` still overrides it.
`.env` is gitignored; `.env.example` is the documented copy.
### API (`api/`)
| Variable | Default | Meaning |
| ------------------------ | --------------------------- | ---------------------------------------------------- |
| `PORT` | `8787` | HTTP port |
| `DB_PATH` | `api/data/cashumints.db` | SQLite file |
| `ICON_DIR` | `api/data/icons` | Cached mint icons, served at `/icons/*` |
| `RELAYS` | see `shared/src/nostr.ts` | Comma separated relay list |
| `PROBE_INTERVAL_MIN` | `10` | Minutes between probe cycles |
| `DISCOVERY_INTERVAL_MIN` | `60` | Minutes between discovery cycles |
| `PROBE_CONCURRENCY` | `8` | Mints probed in parallel |
| `PROBE_TIMEOUT_MS` | `5000` | Per-mint request timeout |
| `SCORE_PRIOR_MEAN` | `3` | Bayesian prior. See "Ranking" below before changing. |
### Web (`web/`)
| Variable | Default | Meaning |
| ---------------- | -------------------------- | --------------------------------------------- |
| `WEB_PORT` | `4321` | Dev server and preview port |
| `API_URL` | `http://127.0.0.1:8787` | API origin used at build time |
| `PUBLIC_API_URL` | empty (same origin) | API origin baked into markup and islands |
| `SITE_URL` | `https://cashumints.space` | Canonical origin for meta tags |
| `RELAYS` | see `shared/src/nostr.ts` | Relays for the build-time review fetch |
| `BONES_URL` | `http://localhost:$WEB_PORT` | Dev server `pnpm bones` captures against |
`PORT` and `API_URL` have to agree: the build and the dev proxy reach the API at
`API_URL`, so moving the API off `8787` means changing both.
`PUBLIC_API_URL` deliberately does **not** fall back to `API_URL`: that is a build-machine
address, and a visitor's browser cannot reach `127.0.0.1`. Left empty, icon `src`
attributes and island fetches are same-origin paths (`/icons/...`, `/api/...`), which the
dev server proxies to `API_URL` and nginx forwards in production — see "Static site behind
nginx" below.
Set it only when the API answers on its own origin:
```bash
API_URL=http://127.0.0.1:8787 PUBLIC_API_URL=https://api.cashumints.space pnpm build
```
## API
Four endpoints, CORS open, no auth.
| Endpoint | Notes |
| -------------------- | ------------------------------------------------------------------ |
| `GET /api/health` | Never cached. 503 when probes are stale or discovery failed. |
| `GET /api/stats` | Network counters, memoized 60s in process. |
| `GET /api/mints` | All mints, online first then score descending. Optional `?limit=`. |
| `GET /api/mints/:host` | One mint plus info, NUTs, distribution, uptime and probe history. |
`/icons/*` serves the cached mint icons.
### Ranking
Bayesian weighted rating:
```
score = (v / (v + m)) * R + (m / (v + m)) * C
```
`v` is the review count, `R` the mint's mean rating, `m` = 5, and `C` the prior mean.
Offline mints are multiplied by 0.5 and always sorted below every online mint; mints with
no review in 180 days are multiplied by 0.9.
**`C` defaults to 3, not the observed global mean.** BACKEND.md specifies the global mean,
but almost every Cashu review is five stars, so that mean sits near 4.7. Shrinking two
above-prior means toward a prior that high cannot reorder them, it only compresses them,
and the result is that a single five star review outranks 39 considered ones. The neutral
prior is what makes the formula behave as intended. Set `SCORE_PRIOR_MEAN=global` for the
literal specified behaviour. `shared/src/score.ts` carries the arithmetic, and
`pnpm --filter ./api test` asserts both cases.
## Deployment
### API as a systemd unit
```ini
# /etc/systemd/system/cashumints-api.service
[Unit]
Description=cashumints.space indexer and API
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=cashumints
WorkingDirectory=/srv/cashumints/api
ExecStart=/usr/bin/node --experimental-strip-types src/index.ts
Environment=NODE_ENV=production
Environment=PORT=8787
Environment=DB_PATH=/var/lib/cashumints/cashumints.db
Environment=ICON_DIR=/var/lib/cashumints/icons
Restart=always
RestartSec=5
# The process finishes its in-flight probe batch and closes the database on SIGTERM.
KillSignal=SIGTERM
TimeoutStopSec=30
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/cashumints
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl enable --now cashumints-api
```
### Static site behind nginx
```nginx
server {
listen 443 ssl http2;
server_name cashumints.space;
root /srv/cashumints/web;
index index.html;
# Astro emits directory-style routes, so try the directory index before 404.
location / {
try_files $uri $uri/ $uri.html /404.html;
}
# A miss under a language prefix answers in that language. Without these two blocks
# every 404 is the English one, including the ones a Spanish reader reaches by
# following a Spanish link, and the language control on it would take them to a page
# they were not on.
location /es/ {
try_files $uri $uri/ $uri.html /es/404/index.html;
}
location /nl/ {
try_files $uri $uri/ $uri.html /nl/404/index.html;
}
# Fingerprinted assets are immutable. This covers the CSS, the island bundles and the
# webfonts, which are all emitted here with a content hash in the name. Serving the
# fonts without it is visible, not theoretical: the view transition router re-inserts
# the font preload links on every navigation, so an uncached font is re-fetched on
# each page change and the typefaces flicker as they reload.
location /_astro/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# The social card, the icons and the manifest. Not fingerprinted, since their names
# are referenced from outside the site, so a week with revalidation rather than a
# year of immutability.
location ~* ^/(og\.png|favicon\.(ico|svg)|apple-touch-icon\.png|icon-\d+\.png|site\.webmanifest)$ {
expires 7d;
add_header Cache-Control "public";
}
# Same-origin API and icons, matching the default empty PUBLIC_API_URL. Drop these two
# blocks only if you build with PUBLIC_API_URL pointing at a separate API host.
location /api/ {
proxy_pass http://127.0.0.1:8787;
}
location /icons/ {
proxy_pass http://127.0.0.1:8787;
expires 1d;
}
error_page 404 /404.html;
}
```
Add a `location` block per language when you add one. The 404 is also the client-side
fallback for a mint discovered since the last build, and it reads the locale off its own
URL, so `/es/mint/some-new-mint` resolves that mint and renders its summary in Spanish.
### API behind nginx, with a micro-cache
`/api/mints` and `/api/stats` change at most every few minutes but can be requested by
every visitor at once. A short micro-cache absorbs that without making the data stale.
`/api/health` is deliberately excluded: it is the endpoint you page on.
```nginx
proxy_cache_path /var/cache/nginx/cashumints levels=1:2 keys_zone=cashumints:10m
max_size=256m inactive=10m use_temp_path=off;
server {
listen 443 ssl http2;
server_name api.cashumints.space;
location /api/health {
proxy_pass http://127.0.0.1:8787;
proxy_cache off;
add_header Cache-Control "no-store" always;
}
location ~ ^/api/(mints|stats) {
proxy_pass http://127.0.0.1:8787;
proxy_cache cashumints;
proxy_cache_valid 200 30s;
proxy_cache_valid 404 10s;
# Serve the previous response while one request refreshes it, so a slow
# backend never becomes a slow page.
proxy_cache_use_stale updating error timeout http_500 http_502 http_503;
proxy_cache_background_update on;
proxy_cache_lock on;
add_header X-Cache-Status $upstream_cache_status always;
}
location /icons/ {
proxy_pass http://127.0.0.1:8787;
proxy_cache cashumints;
proxy_cache_valid 200 1d;
}
}
```
### Rebuilds
Mint pages are prerendered, so new mints and new review counts appear at the next build.
A nightly rebuild is enough; the site stays correct in between because the islands refresh
status and reviews at runtime, and an unbuilt mint still resolves through the client-side
fallback on the 404 page.
```ini
# /etc/systemd/system/cashumints-build.service
[Unit]
Description=Rebuild the cashumints.space static site
[Service]
Type=oneshot
User=cashumints
WorkingDirectory=/srv/cashumints
Environment=API_URL=http://127.0.0.1:8787
Environment=PUBLIC_API_URL=https://api.cashumints.space
Environment=SITE_URL=https://cashumints.space
ExecStart=/usr/bin/pnpm build
ExecStartPost=/usr/bin/rsync -a --delete web/dist/ /srv/cashumints/web/
```
```ini
# /etc/systemd/system/cashumints-build.timer
[Unit]
Description=Nightly cashumints.space rebuild
[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true
[Install]
WantedBy=timers.target
```
```bash
sudo systemctl enable --now cashumints-build.timer
```
## Licence
See `LICENSE`.
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@cashumints/api",
"version": "2.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "node --env-file-if-exists=../.env --experimental-strip-types --watch src/index.ts",
"start": "node --env-file-if-exists=../.env --experimental-strip-types src/index.ts",
"seed": "node --env-file-if-exists=../.env --experimental-strip-types src/seed.ts",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "node --env-file-if-exists=../.env --experimental-strip-types src/check.ts",
"test:warnings": "node --env-file-if-exists=../.env --experimental-strip-types src/check-warnings.ts",
"test:offline": "node --env-file-if-exists=../.env --experimental-strip-types src/check-offline.ts"
},
"dependencies": {
"@cashumints/shared": "workspace:*",
"@hono/node-server": "^1.13.7",
"better-sqlite3": "^11.7.0",
"hono": "^4.6.14",
"nostr-tools": "^2.10.4"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
"@types/node": "^22.10.2",
"typescript": "^5.6.3"
}
}
+102
View File
@@ -0,0 +1,102 @@
/**
* Acceptance criterion #1, as a runnable check.
*
* pnpm --filter ./api test:offline
*
* A mint whose /v1/info is unreachable must still serve its full detail payload from
* cached metadata. This is the single most important fix over the old site, so it gets
* a test that actually kills a mint rather than a unit test of a helper.
*
* Runs against a throwaway copy of the seeded database, so the real one is untouched.
*/
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
const source = process.env['DB_PATH'] ?? path.resolve(import.meta.dirname, '..', 'data', 'cashumints.db');
if (!fs.existsSync(source)) {
console.error(`No seeded database at ${source}. Run "pnpm seed" first.`);
process.exit(1);
}
// Point the whole process at a copy before anything opens the real database.
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'cashumints-offline-'));
const copy = path.join(scratch, 'test.db');
fs.copyFileSync(source, copy);
process.env['DB_PATH'] = copy;
const { getDb, closeDb } = await import('./db.ts');
const { probeMint } = await import('./probe.ts');
const { getMintDetail } = await import('./queries.ts');
const { mintByUrl } = await import('./mints.ts');
const db = getDb();
// Pick a mint that is online and has real cached metadata to lose.
const victim = db
.prepare(
`SELECT * FROM mints
WHERE status = 'online' AND info_json IS NOT NULL AND name IS NOT NULL
ORDER BY LENGTH(info_json) DESC LIMIT 1`,
)
.get() as { url: string; host: string; name: string } | undefined;
assert.ok(victim, 'seeded database has no online mint with cached metadata');
console.log(`victim: ${victim.name} (${victim.host})`);
const before = getMintDetail(victim.host);
assert.ok(before, 'detail must exist before the mint dies');
assert.equal(before.status, 'online');
assert.ok(before.info, 'must have cached /v1/info before');
assert.ok(before.nuts.length > 0, 'must have parsed NUTs before');
// Rug it: same row, dead URL. This is the "point a mint row at a dead URL" test.
const DEAD = 'https://this-mint-is-gone.invalid';
db.prepare('UPDATE mints SET url = ? WHERE host = ?').run(DEAD, victim.host);
db.prepare('UPDATE reviews SET mint_url = ? WHERE mint_url = ?').run(DEAD, victim.url);
// Three failures is the offline threshold.
for (let i = 0; i < 3; i++) {
const row = mintByUrl(DEAD);
assert.ok(row, 'row must survive the URL change');
const result = await probeMint(row);
assert.equal(result.ok, false, 'a dead URL must not probe ok');
}
const after = getMintDetail(victim.host);
assert.ok(after, 'the mint page must still resolve when the mint is dead');
assert.equal(after.status, 'offline', 'three failures means offline');
// The whole point: none of the cached metadata was touched by the failed probes.
assert.equal(after.name, before.name, 'name survives');
assert.equal(after.description, before.description, 'description survives');
assert.equal(after.version, before.version, 'version survives');
assert.equal(after.pubkey, before.pubkey, 'pubkey survives');
assert.deepEqual(after.nuts, before.nuts, 'supported NUTs survive');
assert.deepEqual(after.info, before.info, 'full cached /v1/info survives');
assert.equal(after.icon, before.icon, 'cached icon survives');
// Reviews must still be readable, so the mint stays reviewable.
assert.equal(after.review_count, before.review_count, 'reviews survive');
assert.deepEqual(after.rating_distribution, before.rating_distribution, 'distribution survives');
// The page needs a date for the "Offline since {date}" banner.
assert.ok(after.last_online !== null, 'last_online must be set so the banner has a date');
assert.ok(after.last_online <= Math.floor(Date.now() / 1000));
// And it must sink below every online mint without disappearing.
const { listMints } = await import('./queries.ts');
const list = listMints();
const index = list.findIndex((m) => m.host === victim.host);
assert.ok(index >= 0, 'an offline mint must stay listed so people can find and review it');
const lastOnline = list.map((m) => m.status).lastIndexOf('online');
assert.ok(index > lastOnline, 'offline mints rank below every online mint');
closeDb();
fs.rmSync(scratch, { recursive: true, force: true });
console.log(
`ok, offline mint still serves ${after.nuts.length} NUTs, ${after.review_count} reviews, ` +
`and full cached metadata (offline since ${new Date(after.last_online * 1000).toISOString()})`,
);
+289
View File
@@ -0,0 +1,289 @@
/**
* pnpm --filter ./api test:warnings
*
* getMintWarnings drives the banner on every mint page, the chip on every card and
* the live upgrade in between, so it gets its own fixture-driven check. Each fixture
* in shared/fixtures/warnings is a real /v1/info payload, or a real one with a single
* flag flipped, and the file says which.
*
* No framework: it throws on the first failure and prints a count on success.
*/
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import {
getMintWarnings, mintChip, primaryWarning, readCapabilities, warningRank,
type MintWarning, type MintWarningInput, type MintWarningKind,
} from '@cashumints/shared';
const DIR = path.resolve(import.meta.dirname, '..', '..', 'shared', 'fixtures', 'warnings');
/** The instant every fixture's dates were written against. */
const NOW = 1_787_000_000;
// Fixed formatters: the assertions are about the copy, not about the runner's locale.
const OPTIONS = {
now: NOW,
formatDate: (unix: number) => new Date(unix * 1000).toISOString().slice(0, 10),
formatMonth: (unix: number) => new Date(unix * 1000).toISOString().slice(0, 7),
};
interface Fixture extends MintWarningInput {
note: string;
}
function load(name: string): Fixture {
return JSON.parse(fs.readFileSync(path.join(DIR, `${name}.json`), 'utf8')) as Fixture;
}
function warnings(name: string): MintWarning[] {
return getMintWarnings(load(name), OPTIONS);
}
let checks = 0;
function check(name: string, fn: () => void): void {
try {
fn();
checks++;
} catch (err) {
console.error(`FAIL: ${name}`);
throw err;
}
}
/** Every fixture, and the banner its mint must show. null means no banner at all. */
const EXPECTED: [string, MintWarningKind | null][] = [
['healthy-cdk', null],
['healthy-nutshell', null],
['no-nut04-nut05', null],
['degraded', null],
['melt-only', 'melt-only'],
['melt-disabled', 'melt-disabled'],
['frozen', 'frozen'],
['no-lightning-methods', 'melt-only'],
['offline-recent', 'offline'],
['offline-long', 'offline-long'],
['offline-gone', 'gone'],
['offline-never', 'gone'],
['offline-gone-melt-disabled', 'gone'],
['offline-recent-melt-disabled', 'melt-disabled'],
['offline-long-melt-only', 'offline-long'],
];
check('every fixture resolves to the banner it is named for', () => {
for (const [name, kind] of EXPECTED) {
const primary = primaryWarning(load(name), OPTIONS);
assert.equal(primary?.kind ?? null, kind, `${name} should show ${kind ?? 'no banner'}`);
}
});
check('the fixture set covers every warning kind plus healthy', () => {
const covered = new Set(EXPECTED.map(([, kind]) => kind));
for (const kind of ['gone', 'melt-disabled', 'frozen', 'offline-long', 'melt-only', 'offline'] as const) {
assert.ok(covered.has(kind), `no fixture produces ${kind}`);
}
assert.ok(covered.has(null), 'no fixture produces a healthy mint');
});
check('a healthy mint produces nothing at all, not an empty-ish banner', () => {
assert.deepEqual(warnings('healthy-cdk'), []);
assert.deepEqual(warnings('healthy-nutshell'), []);
assert.deepEqual(warnings('no-nut04-nut05'), [], 'a NUT nobody published is not a NUT switched off');
assert.deepEqual(warnings('degraded'), [], 'degraded is not offline');
});
check('melt only: NUT-04 disabled, NUT-05 alive (mint.belgianbitcoinembassy.org)', () => {
const [w, ...rest] = warnings('melt-only');
assert.equal(rest.length, 0, 'one condition, one warning');
assert.equal(w?.severity, 'warning');
assert.ok(w?.lead.startsWith('Melt only.'));
assert.match(w!.body, /New minting \(NUT-04\) is disabled/);
assert.match(w!.body, /existing ecash can still be withdrawn/);
assert.equal(w?.meta, 'Currently melt only');
});
check('withdrawals disabled outranks everything but a mint being gone', () => {
const w = warnings('melt-disabled')[0];
assert.equal(w?.kind, 'melt-disabled');
assert.equal(w?.severity, 'critical', 'sats in with no way out is critical, not a nudge');
assert.ok(w?.lead.startsWith('Withdrawals disabled.'));
assert.match(w!.body, /you can put sats in, but you cannot withdraw them/);
assert.ok(warningRank('melt-disabled') < warningRank('melt-only'));
assert.ok(warningRank('gone') < warningRank('melt-disabled'));
});
check('both disabled collapses into one frozen banner (lekmint.cash)', () => {
const list = warnings('frozen');
assert.equal(list.length, 1, 'frozen replaces the melt-disabled and melt-only banners');
assert.equal(list[0]?.kind, 'frozen');
assert.equal(list[0]?.severity, 'critical');
assert.match(list[0]!.body, /Both minting and melting are disabled/);
assert.ok(!list.some((w) => w.kind === 'melt-disabled' || w.kind === 'melt-only'));
});
check('an empty methods list is off, whatever the flag says', () => {
// LekMint publishes disabled: true AND methods: []. Either one alone must be enough.
assert.deepEqual(readCapabilities({ '4': { methods: [] }, '5': { methods: [] } }), {
mintDisabled: true, meltDisabled: true, mintPublished: true, meltPublished: true,
});
const onchainOnly = warnings('no-lightning-methods')[0];
assert.equal(onchainOnly?.kind, 'melt-only', 'onchain-only minting cannot take a Lightning deposit');
});
check('capabilities read the shapes both mint softwares publish', () => {
// Nutshell and cdk-mintd both use plain numeric keys; the prefixed spellings are
// accepted because parseNuts has always accepted them.
const shapes: unknown[] = [
{ '4': { methods: [{ method: 'bolt11', unit: 'sat' }], disabled: true } },
{ nut4: { methods: [{ method: 'bolt11', unit: 'sat' }], disabled: true } },
{ 'NUT-04': { methods: [{ method: 'bolt11', unit: 'sat' }], disabled: true } },
];
for (const nuts of shapes) assert.equal(readCapabilities(nuts).mintDisabled, true);
// An absent flag means enabled: several Nutshell builds omit it.
assert.equal(readCapabilities({ '5': { methods: [{ method: 'bolt11', unit: 'sat' }] } }).meltDisabled, false);
// bolt12 is Lightning too, so a bolt12-only mint is not "disabled".
assert.equal(readCapabilities({ '4': { methods: [{ method: 'bolt12', unit: 'sat' }] } }).mintDisabled, false);
// Nothing published at all: no claim either way.
assert.deepEqual(readCapabilities(undefined), {
mintDisabled: false, meltDisabled: false, mintPublished: false, meltPublished: false,
});
});
check('offline tiers split at 7 and 30 days', () => {
const base = load('offline-long');
const at = (days: number) =>
primaryWarning({ ...base, last_online: NOW - days * 86_400 }, OPTIONS)?.kind;
assert.equal(at(0), 'offline');
assert.equal(at(6), 'offline');
assert.equal(at(7), 'offline-long', '7 days is the first day of tier 2');
assert.equal(at(29), 'offline-long');
assert.equal(at(30), 'gone', '30 days is the first day of tier 3');
assert.equal(at(400), 'gone');
});
check('an offline mint that has never answered is treated as gone, not as fresh', () => {
const w = warnings('offline-never')[0];
assert.equal(w?.kind, 'gone');
assert.equal(w?.severity, 'critical');
assert.match(w!.body, /has not answered a single check/);
assert.match(w!.body, /watching it in 2026-08/, 'dates the silence from first_seen');
assert.ok(!w!.body.includes('NaN') && !w!.body.includes('null'));
assert.equal(w?.meta, 'Never reachable since it was discovered');
});
check('day counts and dates are the real ones, and read as English', () => {
const gone = warnings('offline-gone')[0];
assert.match(gone!.body, /unreachable since 2026-06-14 \(64 days\)/);
const recent = warnings('offline-recent')[0];
assert.equal(recent?.severity, 'warning');
assert.equal(recent?.lead, 'Offline since 2026-08-14.');
const oneDay = primaryWarning({ ...load('offline-recent'), last_online: NOW - 86_400 }, OPTIONS);
assert.equal(oneDay?.lead, 'Offline since 2026-08-16.', 'singular day, no "1 days"');
assert.equal(
primaryWarning({ ...load('offline-long'), last_online: NOW - 7 * 86_400 }, OPTIONS)?.lead,
'Offline for 7 days, since 2026-08-10.',
);
});
check('offline 30+ days plus disabled withdrawals is one banner with one extra sentence', () => {
const list = warnings('offline-gone-melt-disabled');
const primary = list[0];
assert.equal(primary?.kind, 'gone', 'the mint being gone is the headline');
assert.ok(primary!.body.endsWith('Before going offline it had also disabled withdrawals.'));
assert.match(primary!.body, /Treat funds held there as at risk/);
// The melt fact is still in the list for the card, it just does not get a banner.
assert.ok(list.some((w) => w.kind === 'melt-disabled'), 'the card still needs to know');
assert.equal(list.filter((w) => w.kind === 'gone').length, 1);
});
check('offline 7 to 30 days plus disabled minting collapses the same way', () => {
const primary = warnings('offline-long-melt-only')[0];
assert.equal(primary?.kind, 'offline-long');
assert.ok(primary!.body.endsWith('Before going offline it had also disabled new minting.'));
});
check('a capability banner that outranks an offline one still says it is offline', () => {
const primary = warnings('offline-recent-melt-disabled')[0];
assert.equal(primary?.kind, 'melt-disabled');
assert.match(primary!.body, /This mint had Lightning melting/, 'cached flags are past tense');
assert.ok(primary!.body.endsWith('It has also been offline since 2026-08-14 (3 days).'));
});
check('cached flags never claim to be current on an offline mint', () => {
assert.match(warnings('offline-recent-melt-disabled')[0]!.body, /\bhad\b/);
assert.match(warnings('melt-disabled')[0]!.body, /\bhas\b/);
assert.match(warnings('melt-only')[0]!.body, /\bis disabled\b/);
const offlineMeltOnly = getMintWarnings(
{ ...load('melt-only'), status: 'offline', last_online: NOW - 86_400 },
OPTIONS,
);
assert.match(offlineMeltOnly[0]!.body, /\bwas disabled\b/);
assert.equal(offlineMeltOnly[0]?.meta, 'Melt only when last seen');
});
check('an offline banner does not repeat itself in its own last sentence', () => {
// The collapse sentence belongs to a capability banner that outranked an offline
// one. On an offline banner the fact is already in the lead.
for (const name of ['offline-recent', 'offline-long', 'offline-gone', 'offline-never']) {
const body = warnings(name)[0]!.body;
assert.ok(!body.includes('It has also been offline'), `${name} says it twice`);
}
});
check('warnings come back in severity order, most severe first', () => {
for (const [name] of EXPECTED) {
const ranks = warnings(name).map((w) => warningRank(w.kind));
assert.deepEqual(ranks, [...ranks].sort((a, b) => a - b), `${name} is out of order`);
}
});
check('cards get a chip for the capability states and nothing for offline alone', () => {
assert.deepEqual(mintChip(warnings('melt-only')), { label: 'Melt only', severity: 'warning' });
assert.deepEqual(mintChip(warnings('frozen')), { label: 'Frozen', severity: 'critical' });
assert.deepEqual(mintChip(warnings('melt-disabled')), { label: 'No withdrawals', severity: 'critical' });
assert.equal(mintChip(warnings('offline-gone')), null, 'offline cards already look offline');
assert.equal(mintChip(warnings('healthy-cdk')), null);
// The chip survives an offline banner outranking the capability warning.
assert.deepEqual(mintChip(warnings('offline-gone-melt-disabled')), {
label: 'No withdrawals', severity: 'critical',
});
});
check('a card can be warned from precomputed flags, with no info payload', () => {
const fromInfo = warnings('frozen');
const fromFlags = getMintWarnings(
{ status: 'online', last_online: NOW - 90, capabilities: { mintDisabled: true, meltDisabled: true } },
OPTIONS,
);
assert.deepEqual(fromFlags.map((w) => w.kind), fromInfo.map((w) => w.kind));
});
check('the copy obeys DESIGN.md: no em dashes, no all-caps, no emoji', () => {
for (const [name] of EXPECTED) {
for (const w of warnings(name)) {
const text = `${w.lead} ${w.body} ${w.meta}`;
assert.ok(!text.includes('—'), `${name}/${w.kind} contains an em dash`);
assert.ok(!/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/u.test(text), `${name}/${w.kind} contains an emoji`);
assert.ok(!/\b[A-Z]{4,}\b/.test(text.replace(/NUT-\d+/g, '')), `${name}/${w.kind} shouts`);
assert.ok(w.lead.length <= 60, `${name}/${w.kind} lead is not a short phrase`);
assert.ok(/[.!?]$/.test(w.lead.trim()) && /[.!?]$/.test(w.body.trim()));
assert.ok(w.meta.length > 0 && !w.meta.endsWith('.'), 'meta is a clause, not a sentence');
}
}
});
check('nothing is warned about without a reason: no now, no crash', () => {
// Called with no options at all, the way a page that forgot them would call it.
assert.deepEqual(getMintWarnings(load('healthy-cdk')), []);
assert.equal(getMintWarnings(load('frozen'))[0]?.kind, 'frozen');
assert.equal(getMintWarnings({ status: 'unknown', last_online: null }).length, 0);
});
console.log(`ok, ${checks} warning checks passed over ${fs.readdirSync(DIR).length} fixtures`);
+215
View File
@@ -0,0 +1,215 @@
/**
* pnpm --filter ./api test
*
* One runnable check over the logic that is easy to get quietly wrong: URL
* normalization, rating parsing, scoring, and the review-dedupe SQL. No framework:
* it throws on the first failure and prints a count on success.
*/
import assert from 'node:assert/strict';
import { bayesianScore, compareMints, NEUTRAL_PRIOR_MEAN, normalizeMintUrl, parseNuts, parseLimits, parseRating } from '@cashumints/shared';
import { statusForFails } from './probe.ts';
let checks = 0;
function check(name: string, fn: () => void): void {
try {
fn();
checks++;
} catch (err) {
console.error(`FAIL: ${name}`);
throw err;
}
}
check('normalize forces https and strips trailing slash', () => {
assert.equal(normalizeMintUrl('http://Mint.Example.com/')?.url, 'https://mint.example.com');
assert.equal(normalizeMintUrl('mint.example.com')?.url, 'https://mint.example.com');
assert.equal(normalizeMintUrl('https://mint.example.com//')?.url, 'https://mint.example.com');
});
check('normalize keeps the path as part of mint identity', () => {
const a = normalizeMintUrl('https://mint.minibits.cash/Bitcoin');
const b = normalizeMintUrl('https://mint.minibits.cash');
assert.equal(a?.url, 'https://mint.minibits.cash/Bitcoin');
assert.notEqual(a?.url, b?.url);
assert.notEqual(a?.host, b?.host);
assert.equal(a?.host, 'mint.minibits.cash-bitcoin');
assert.equal(b?.host, 'mint.minibits.cash');
});
check('normalize collapses the trailing-slash duplicate the old site created', () => {
const withSlash = normalizeMintUrl('https://21mint.me/');
const without = normalizeMintUrl('https://21mint.me');
assert.equal(withSlash?.url, without?.url);
assert.equal(withSlash?.host, without?.host);
});
check('normalize drops default ports but keeps explicit ones', () => {
assert.equal(normalizeMintUrl('https://x.example.com:443')?.url, 'https://x.example.com');
assert.equal(normalizeMintUrl('https://smilemoji.cash:3338')?.url, 'https://smilemoji.cash:3338');
assert.equal(normalizeMintUrl('https://smilemoji.cash:3338')?.host, 'smilemoji.cash-3338');
});
check('normalize rejects onion, localhost and private ranges', () => {
assert.equal(normalizeMintUrl('http://2tcc5ozz.onion'), null);
assert.equal(normalizeMintUrl('http://localhost:3338'), null);
assert.equal(normalizeMintUrl('https://192.168.1.5:3338'), null);
assert.equal(normalizeMintUrl('https://127.0.0.1'), null);
assert.equal(normalizeMintUrl('ftp://mint.example.com'), null);
assert.equal(normalizeMintUrl(''), null);
});
check('rating comes from the [N/5] content prefix when there is no rating tag', () => {
// This is the shape the old publisher actually shipped, and most reviews on the
// network have no rating tag at all.
const event = {
id: 'a',
pubkey: 'b',
kind: 38000,
created_at: 1,
content: '[4/5] Solid mint, fast melts',
tags: [['k', '38172'], ['u', 'https://mint.example.com', 'cashu']],
};
assert.equal(parseRating(event), 4);
});
check('rating tag wins over content', () => {
assert.equal(
parseRating({
id: 'a', pubkey: 'b', kind: 38000, created_at: 1,
content: '[1/5] ignore me',
tags: [['rating', '5']],
}),
5,
);
});
check('unparseable rating is null, never a silent 5', () => {
// The old parser defaulted to 5 here, which is why every mint showed 5/5.
assert.equal(
parseRating({ id: 'a', pubkey: 'b', kind: 38000, created_at: 1, content: 'nice', tags: [] }),
null,
);
});
check('fractional rating tags are mapped onto 1..5', () => {
assert.equal(
parseRating({ id: 'a', pubkey: 'b', kind: 38000, created_at: 1, content: '', tags: [['rating', '0.8']] }),
4,
);
});
check('many reviews at 4.6 outrank few at 5.0 (the point of the whole formula)', () => {
const now = 1_800_000_000;
const at = (v: number, R: number) =>
bayesianScore(
{ review_count: v, rating_avg: R, status: 'online', last_review_at: now - 3600 },
NEUTRAL_PRIOR_MEAN, now,
);
assert.ok(at(39, 4.6) > at(4, 5.0), `39@4.6=${at(39, 4.6)} must beat 4@5.0=${at(4, 5.0)}`);
assert.ok(at(39, 4.6) > at(1, 5.0), 'and must beat a lone five star review');
assert.ok(at(4, 5.0) > at(1, 5.0), 'among equal means, more reviews wins');
});
check('the observed global mean as prior breaks that guarantee', () => {
// Documents why NEUTRAL_PRIOR_MEAN is the default instead of the literal
// BACKEND.md "C = global mean". Cashu reviews are almost all five stars, so the
// observed mean sits near 4.7 and shrinking toward it cannot reorder two means
// that are both above it. If this check ever starts failing, the network's rating
// spread changed and the default prior is worth revisiting.
const now = 1_800_000_000;
const observed = 4.7;
const many = bayesianScore(
{ review_count: 39, rating_avg: 4.6, status: 'online', last_review_at: now - 3600 },
observed, now,
);
const one = bayesianScore(
{ review_count: 1, rating_avg: 5.0, status: 'online', last_review_at: now - 3600 },
observed, now,
);
assert.ok(one > many, 'with C=4.7 a single 5 star review outranks 39 considered ones');
});
check('offline mints are penalised and always sort below online ones', () => {
const now = 1_800_000_000;
const input = { review_count: 20, rating_avg: 5, status: 'offline', last_review_at: now - 100 };
const off = bayesianScore(input, 4.5, now);
const on = bayesianScore({ ...input, status: 'online' }, 4.5, now);
assert.ok(off < on);
// A perfect offline mint still ranks below a mediocre online one.
const sorted = [
{ status: 'offline', score: 4.9, review_count: 99 },
{ status: 'online', score: 1.2, review_count: 1 },
].sort(compareMints);
assert.equal(sorted[0]?.status, 'online');
});
check('stale mints lose 10 percent', () => {
const now = 1_800_000_000;
const base = { review_count: 10, rating_avg: 4.5, status: 'online' };
const fresh = bayesianScore({ ...base, last_review_at: now - 100 }, 4.5, now);
const stale = bayesianScore({ ...base, last_review_at: now - 200 * 86400 }, 4.5, now);
assert.ok(Math.abs(stale - fresh * 0.9) < 0.002, `${stale} vs ${fresh * 0.9}`);
// Never reviewed counts as stale too.
assert.ok(bayesianScore({ ...base, last_review_at: null }, 4.5, now) < fresh);
});
check('probe status ladder: 1-2 fails degraded, 3+ offline', () => {
assert.equal(statusForFails(0), 'online');
assert.equal(statusForFails(1), 'degraded');
assert.equal(statusForFails(2), 'degraded');
assert.equal(statusForFails(3), 'offline');
});
check('nuts parse handles all three encodings seen in the wild', () => {
const nuts = parseNuts({
'4': { methods: [{ method: 'bolt11', unit: 'sat' }], disabled: false },
'5': { methods: [{ method: 'bolt11', unit: 'sat' }] },
'7': { supported: true },
'9': { supported: false },
'15': { supported: [{ method: 'bolt11', unit: 'sat' }] },
'16': { supported: [] },
'17': true,
'19': { disabled: true, methods: [{ method: 'bolt11' }] },
});
assert.deepEqual(nuts, ['4', '5', '7', '15', '17']);
});
check('limits come from NUT-04 methods', () => {
const limits = parseLimits({
'4': { methods: [{ method: 'bolt11', unit: 'sat', min_amount: 1, max_amount: 1_000_000 }] },
});
assert.deepEqual(limits, { min: 1, max: 1_000_000, unit: 'sat' });
assert.equal(parseLimits({ '4': { methods: [{ method: 'bolt11' }] } }), null);
assert.equal(parseLimits(undefined), null);
});
// The dedupe rule lives in SQL, so exercise it against a real in-memory database
// using the same statement the API uses.
check('one review per author per mint, newest wins', async () => {
const { default: Database } = await import('better-sqlite3');
const db = new Database(':memory:');
db.exec(`CREATE TABLE reviews (event_id TEXT PRIMARY KEY, mint_url TEXT, pubkey TEXT,
rating INTEGER, created_at INTEGER)`);
const insert = db.prepare('INSERT INTO reviews VALUES (?,?,?,?,?)');
insert.run('e1', 'https://m', 'alice', 1, 100);
insert.run('e2', 'https://m', 'alice', 5, 200); // alice changed her mind
insert.run('e3', 'https://m', 'bob', 3, 150);
const rows = db
.prepare(
`SELECT pubkey, rating FROM (
SELECT pubkey, rating, ROW_NUMBER() OVER (
PARTITION BY mint_url, pubkey ORDER BY created_at DESC, event_id) rn
FROM reviews) WHERE rn = 1 ORDER BY pubkey`,
)
.all() as { pubkey: string; rating: number }[];
assert.equal(rows.length, 2, 'alice must count once');
assert.equal(rows.find((r) => r.pubkey === 'alice')?.rating, 5, 'newest review wins');
db.close();
});
// The last check is async, so report after the microtask queue drains.
queueMicrotask(() => console.log(`ok, ${checks} checks passed`));
+28
View File
@@ -0,0 +1,28 @@
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { DEFAULT_RELAYS } from '@cashumints/shared';
const here = path.dirname(fileURLToPath(import.meta.url));
const apiRoot = path.resolve(here, '..');
function int(name: string, fallback: number): number {
const raw = process.env[name];
if (!raw) return fallback;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) && n > 0 ? n : fallback;
}
export const config = {
port: int('PORT', 8787),
dbPath: process.env['DB_PATH'] ?? path.join(apiRoot, 'data', 'cashumints.db'),
iconDir: process.env['ICON_DIR'] ?? path.join(apiRoot, 'data', 'icons'),
relays: (process.env['RELAYS']?.split(',').map((r) => r.trim()).filter(Boolean) ??
[...DEFAULT_RELAYS]) as string[],
probeIntervalMin: int('PROBE_INTERVAL_MIN', 10),
discoveryIntervalMin: int('DISCOVERY_INTERVAL_MIN', 60),
probeConcurrency: int('PROBE_CONCURRENCY', 8),
probeTimeoutMs: int('PROBE_TIMEOUT_MS', 5000),
userAgent: 'cashumints.space-indexer/1.0',
} as const;
export const startedAt = Math.floor(Date.now() / 1000);
+101
View File
@@ -0,0 +1,101 @@
import Database from 'better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { config } from './config.ts';
const SCHEMA = `
CREATE TABLE IF NOT EXISTS mints (
url TEXT PRIMARY KEY,
host TEXT NOT NULL,
name TEXT,
description TEXT,
icon_url TEXT,
icon_file TEXT,
pubkey TEXT,
info_json TEXT,
nuts_json TEXT,
version TEXT,
status TEXT NOT NULL DEFAULT 'unknown',
consecutive_fails INTEGER NOT NULL DEFAULT 0,
last_online INTEGER,
last_probe INTEGER,
first_seen INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_mints_host ON mints(host);
CREATE INDEX IF NOT EXISTS idx_mints_pubkey ON mints(pubkey);
CREATE TABLE IF NOT EXISTS reviews (
event_id TEXT PRIMARY KEY,
mint_url TEXT NOT NULL,
pubkey TEXT NOT NULL,
rating INTEGER,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_reviews_mint ON reviews(mint_url, created_at);
CREATE INDEX IF NOT EXISTS idx_reviews_author ON reviews(mint_url, pubkey, created_at);
CREATE TABLE IF NOT EXISTS probes (
mint_url TEXT NOT NULL,
ts INTEGER NOT NULL,
ok INTEGER NOT NULL,
latency_ms INTEGER
);
CREATE INDEX IF NOT EXISTS idx_probes_mint ON probes(mint_url, ts);
CREATE TABLE IF NOT EXISTS state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`;
export type DB = Database.Database;
let db: DB | null = null;
export function getDb(): DB {
if (db) return db;
fs.mkdirSync(path.dirname(config.dbPath), { recursive: true });
fs.mkdirSync(config.iconDir, { recursive: true });
const handle = new Database(config.dbPath);
handle.pragma('journal_mode = WAL');
handle.pragma('synchronous = NORMAL');
handle.pragma('busy_timeout = 5000');
handle.exec(SCHEMA);
db = handle;
return handle;
}
export function closeDb(): void {
db?.close();
db = null;
}
export function getState(key: string): string | null {
const row = getDb().prepare('SELECT value FROM state WHERE key = ?').get(key) as
| { value: string }
| undefined;
return row?.value ?? null;
}
export function setState(key: string, value: string): void {
getDb()
.prepare('INSERT INTO state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
.run(key, value);
}
export function getStateNumber(key: string): number | null {
const raw = getState(key);
if (raw === null) return null;
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) ? n : null;
}
/** Delete probe rows older than 90 days. Called once a day. */
export function pruneProbes(): number {
const cutoff = Math.floor(Date.now() / 1000) - 90 * 24 * 60 * 60;
return getDb().prepare('DELETE FROM probes WHERE ts < ?').run(cutoff).changes;
}
+257
View File
@@ -0,0 +1,257 @@
import { SimplePool } from 'nostr-tools/pool';
import type { Event as NostrEvent, Filter } from 'nostr-tools';
import {
KIND_MINT_ANNOUNCEMENT,
KIND_REVIEW,
isCashuMintReview,
mintPubkeyRef,
mintUrlSpellings,
mintUrlsFromEvent,
normalizeMintUrl,
parseRating,
} from '@cashumints/shared';
import { config } from './config.ts';
import { getDb, getStateNumber, setState } from './db.ts';
import { log } from './log.ts';
import { insertMintIfNew, mintUrlByHost, mintUrlByPubkey } from './mints.ts';
const QUERY_LIMIT = 500;
const MAX_WAIT_MS = 12_000;
/** Backfill page cap. 20 pages x 500 is far more than the network holds today. */
const MAX_PAGES = 20;
/** The old site's second filter: a recent window alongside the unbounded one, because
* some relays answer an unbounded query with an arbitrary slice. See NOTES.md. */
const RECENT_WINDOW_S = 30 * 24 * 60 * 60;
export interface DiscoveryResult {
events: number;
newMints: string[];
newReviews: number;
ok: boolean;
}
let pool: SimplePool | null = null;
function getPool(): SimplePool {
pool ??= new SimplePool();
return pool;
}
export function closePool(): void {
try {
pool?.close(config.relays);
} catch {
// A relay that is already gone throws on close. Nothing to do about it.
}
pool = null;
}
/**
* Query one kind, paging backwards with `until` until a page yields nothing new.
* Relays cap `limit` independently, so paging is the only way a fresh database
* converges to the complete history.
*/
async function fetchKind(kind: number, since: number | null): Promise<NostrEvent[]> {
const seen = new Map<string, NostrEvent>();
let until: number | undefined;
for (let page = 0; page < MAX_PAGES; page++) {
const filter: Filter = { kinds: [kind], limit: QUERY_LIMIT };
if (since !== null) filter.since = since;
if (until !== undefined) filter.until = until;
let batch: NostrEvent[];
try {
batch = await getPool().querySync(config.relays, filter, { maxWait: MAX_WAIT_MS });
} catch (err) {
log.warn('relay query failed', {
kind,
page,
reason: err instanceof Error ? err.message : String(err),
});
break;
}
let added = 0;
let oldest = Number.POSITIVE_INFINITY;
for (const e of batch) {
oldest = Math.min(oldest, e.created_at);
if (!seen.has(e.id)) {
seen.set(e.id, e);
added++;
}
}
// Nothing new, or the relays returned less than a full page: history is exhausted.
if (added === 0 || batch.length < QUERY_LIMIT || !Number.isFinite(oldest)) break;
until = oldest - 1;
if (since !== null && until <= since) break;
}
return [...seen.values()];
}
/**
* Reviews for one specific mint, asked for by name.
*
* The global sweep above does NOT converge on its own: relays answer an unbounded
* `{kinds:[38000]}` with an arbitrary capped slice, but answer a filtered query for one
* mint in full. Measured against the live network, the global sweep found 43 reviews for
* mint.minibits.cash/Bitcoin while this targeted query finds 80. Without this pass the
* count in the API disagrees with the list the browser renders on the same page.
*/
async function fetchReviewsForMint(
mintUrl: string,
mintPubkey: string | null,
since: number | null,
): Promise<NostrEvent[]> {
const filters: Filter[] = [];
const base: Filter = { kinds: [KIND_REVIEW], limit: QUERY_LIMIT };
if (since !== null) base.since = since;
if (mintPubkey) {
filters.push({ ...base, '#d': [mintPubkey], '#k': [String(KIND_MINT_ANNOUNCEMENT)] });
}
filters.push({ ...base, '#u': mintUrlSpellings(mintUrl) });
const batches = await Promise.all(
filters.map((filter) =>
getPool()
.querySync(config.relays, filter, { maxWait: MAX_WAIT_MS })
.catch(() => [] as NostrEvent[]),
),
);
return batches.flat();
}
/** Every mint URL an event points at, normalized and inserted if new. */
function ingestMintUrls(event: NostrEvent, now: number, newMints: Set<string>): void {
for (const raw of mintUrlsFromEvent(event)) {
const created = insertMintIfNew(raw, now);
if (created) newMints.add(created);
}
}
/**
* Resolve which mint a review is about.
*
* `u` tag first, resolved through the slug so that every spelling of a mint URL (with
* or without a trailing slash, either path case) lands on the one canonical row. Then
* the `d` tag pubkey against the pubkey a mint publishes in its own /v1/info, which
* catches reviews whose `u` tag is missing or wrong.
*/
function resolveReviewTarget(event: NostrEvent): string | null {
for (const raw of mintUrlsFromEvent(event)) {
const normalized = normalizeMintUrl(raw);
if (!normalized) continue;
// The row was inserted moments ago by ingestMintUrls, so this almost always hits.
const canonical = mintUrlByHost(normalized.host);
if (canonical) return canonical;
}
const pubkey = mintPubkeyRef(event);
return pubkey ? mintUrlByPubkey(pubkey) : null;
}
export async function runDiscovery(backfill: boolean): Promise<DiscoveryResult> {
const started = Date.now();
const now = Math.floor(Date.now() / 1000);
const lastRun = backfill ? null : getStateNumber('discovery_since');
// Overlap the window by an hour so an event that arrived late is not missed.
const since = lastRun === null ? null : Math.max(0, lastRun - 3600);
const newMints = new Set<string>();
let newReviews = 0;
let events = 0;
let ok = true;
try {
const announcements = await fetchKind(KIND_MINT_ANNOUNCEMENT, since);
events += announcements.length;
for (const e of announcements) ingestMintUrls(e, now, newMints);
// Announcements alone miss mints that only ever appear in a review's `u` tag,
// so reviews feed discovery too.
const reviews = await fetchKind(KIND_REVIEW, since);
// The recent window catches anything a relay dropped from the unbounded query.
const recent =
since === null ? await fetchKind(KIND_REVIEW, now - RECENT_WINDOW_S) : [];
const byId = new Map<string, NostrEvent>();
for (const e of [...reviews, ...recent]) byId.set(e.id, e);
events += byId.size;
for (const e of byId.values()) ingestMintUrls(e, now, newMints);
const insert = getDb().prepare(
`INSERT INTO reviews (event_id, mint_url, pubkey, rating, created_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(event_id) DO UPDATE SET
mint_url = excluded.mint_url,
rating = excluded.rating`,
);
// `changes` counts updates as well as inserts, so ask first. The log line is a
// health signal (is discovery finding anything?) and an inflated number hides a
// stalled crawl behind a busy-looking figure.
const known = getDb().prepare('SELECT 1 FROM reviews WHERE event_id = ?');
const ingestReviews = getDb().transaction((list: NostrEvent[]) => {
for (const e of list) {
// The `k` tag is what marks a 38000 as being about a Cashu mint rather than
// some other recommendable kind. Events without it but with a resolvable mint
// URL are still accepted: the old publisher's own events are the reason.
const target = resolveReviewTarget(e);
if (!target) continue;
if (!isCashuMintReview(e) && mintUrlsFromEvent(e).length === 0) continue;
if (!known.get(e.id)) newReviews++;
insert.run(e.id, target, e.pubkey, parseRating(e), e.created_at);
}
});
ingestReviews([...byId.values()]);
// Second pass: ask each known mint's reviews by name, which is the only way the
// counts converge (see fetchReviewsForMint). Runs after the global sweep so mints
// discovered in this cycle are included.
const targets = getDb()
.prepare('SELECT url, pubkey FROM mints')
.all() as { url: string; pubkey: string | null }[];
const CONCURRENCY = 6;
let cursor = 0;
await Promise.all(
Array.from({ length: Math.min(CONCURRENCY, targets.length) }, async () => {
while (cursor < targets.length) {
const target = targets[cursor++];
if (!target) continue;
const found = await fetchReviewsForMint(target.url, target.pubkey, since);
if (found.length > 0) {
events += found.length;
ingestReviews(found);
}
}
}),
);
setState('discovery_since', String(now));
setState('last_discovery_at', String(now));
setState('last_discovery_ok', '1');
} catch (err) {
ok = false;
setState('last_discovery_ok', '0');
log.error('discovery failed', { reason: err instanceof Error ? err.message : String(err) });
}
log.info('discovery cycle', {
mode: backfill ? 'backfill' : 'incremental',
events,
new_mints: newMints.size,
new_reviews: newReviews,
ok,
ms: Date.now() - started,
});
return { events, newMints: [...newMints], newReviews, ok };
}
+65
View File
@@ -0,0 +1,65 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { config } from './config.ts';
import { log } from './log.ts';
import type { MintRow } from './mints.ts';
const EXT_BY_TYPE: Record<string, string> = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/webp': '.webp',
'image/svg+xml': '.svg',
'image/gif': '.gif',
'image/x-icon': '.ico',
'image/vnd.microsoft.icon': '.ico',
};
const MAX_ICON_BYTES = 512 * 1024;
/**
* Cache a mint's icon to disk so offline mints keep theirs.
* Re-fetches only when the icon_url changed, per BACKEND.md.
* Returns the stored filename, or the existing one if nothing needed doing.
*/
export async function cacheIcon(row: MintRow, iconUrl: string | null): Promise<string | null> {
if (!iconUrl) return row.icon_file;
if (iconUrl === row.icon_url && row.icon_file) return row.icon_file;
// Some mints inline the icon as a data URI. Nothing to fetch, and nothing worth storing.
if (iconUrl.startsWith('data:')) return row.icon_file;
try {
const absolute = new URL(iconUrl, `${row.url}/`).toString();
if (!absolute.startsWith('https://') && !absolute.startsWith('http://')) return row.icon_file;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.probeTimeoutMs);
let res: Response;
try {
res = await fetch(absolute, {
signal: controller.signal,
headers: { 'User-Agent': config.userAgent },
});
} finally {
clearTimeout(timer);
}
if (!res.ok) return row.icon_file;
const type = (res.headers.get('content-type') ?? '').split(';')[0]?.trim() ?? '';
const ext = EXT_BY_TYPE[type];
if (!ext) return row.icon_file;
const buf = Buffer.from(await res.arrayBuffer());
if (buf.byteLength === 0 || buf.byteLength > MAX_ICON_BYTES) return row.icon_file;
const filename = `${row.host}${ext}`;
await fs.writeFile(path.join(config.iconDir, filename), buf);
return filename;
} catch (err) {
log.warn('icon fetch failed', {
url: row.url,
reason: err instanceof Error ? err.message : String(err),
});
return row.icon_file;
}
}
+95
View File
@@ -0,0 +1,95 @@
import { serve } from '@hono/node-server';
import { config } from './config.ts';
import { closeDb, getDb, pruneProbes } from './db.ts';
import { closePool, runDiscovery } from './discovery.ts';
import { log } from './log.ts';
import { probeAll, probeUrls } from './probe.ts';
import { createApp } from './server.ts';
const DAY_MS = 24 * 60 * 60 * 1000;
let shuttingDown = false;
/** Set while a probe or discovery cycle is in flight, so shutdown can wait for it. */
let inFlight: Promise<unknown> = Promise.resolve();
function track<T>(work: Promise<T>): Promise<T> {
inFlight = inFlight.then(() => work).catch(() => undefined);
return work;
}
async function probeCycle(): Promise<void> {
if (shuttingDown) return;
try {
await track(probeAll());
} catch (err) {
log.error('probe cycle failed', { reason: err instanceof Error ? err.message : String(err) });
}
}
async function discoveryCycle(backfill: boolean): Promise<void> {
if (shuttingDown) return;
try {
const result = await track(runDiscovery(backfill));
// New mints are probed straight away, not on the next cycle, so their page has
// real metadata as soon as it is reachable.
if (result.newMints.length > 0) await track(probeUrls(result.newMints));
} catch (err) {
log.error('discovery cycle failed', {
reason: err instanceof Error ? err.message : String(err),
});
}
}
function main(): void {
getDb();
const server = serve({ fetch: createApp().fetch, port: config.port }, (info) => {
log.info('api listening', {
port: info.port,
db: config.dbPath,
relays: config.relays.length,
});
});
const probeTimer = setInterval(() => void probeCycle(), config.probeIntervalMin * 60 * 1000);
const discoveryTimer = setInterval(
() => void discoveryCycle(false),
config.discoveryIntervalMin * 60 * 1000,
);
const pruneTimer = setInterval(() => {
const removed = pruneProbes();
if (removed > 0) log.info('pruned probes', { rows: removed });
}, DAY_MS);
// Startup: probe what we already know, then a full discovery backfill so a fresh
// database converges to complete review counts (BACKEND.md).
void probeCycle().then(() => discoveryCycle(true));
const shutdown = (signal: string) => {
if (shuttingDown) return;
shuttingDown = true;
log.info('shutting down', { signal });
clearInterval(probeTimer);
clearInterval(discoveryTimer);
clearInterval(pruneTimer);
void inFlight.finally(() => {
closePool();
server.close(() => {
closeDb();
process.exit(0);
});
// Do not hang forever on a relay socket that refuses to close.
setTimeout(() => {
closeDb();
process.exit(0);
}, 8000).unref();
});
};
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
}
main();
+21
View File
@@ -0,0 +1,21 @@
/** One line per event, key=value after the message, so logs grep cleanly. */
type Fields = Record<string, string | number | boolean | null | undefined>;
function emit(level: 'info' | 'warn' | 'error', msg: string, fields?: Fields): void {
const parts = [new Date().toISOString(), level.toUpperCase(), msg];
if (fields) {
for (const [k, v] of Object.entries(fields)) {
if (v !== undefined && v !== null) parts.push(`${k}=${v}`);
}
}
const line = parts.join(' ');
if (level === 'error') console.error(line);
else if (level === 'warn') console.warn(line);
else console.log(line);
}
export const log = {
info: (msg: string, fields?: Fields) => emit('info', msg, fields),
warn: (msg: string, fields?: Fields) => emit('warn', msg, fields),
error: (msg: string, fields?: Fields) => emit('error', msg, fields),
};
+94
View File
@@ -0,0 +1,94 @@
import { normalizeMintUrl } from '@cashumints/shared';
import { getDb } from './db.ts';
import { log } from './log.ts';
/**
* URLs already reported as unusable. A rejected value (a Fedimint `fed11…` invite, an
* onion address) recurs in dozens of events per cycle, and logging each occurrence
* buries the lines that matter.
*/
const reportedSkips = new Set<string>();
export interface MintRow {
url: string;
host: string;
name: string | null;
description: string | null;
icon_url: string | null;
icon_file: string | null;
pubkey: string | null;
info_json: string | null;
nuts_json: string | null;
version: string | null;
status: string;
consecutive_fails: number;
last_online: number | null;
last_probe: number | null;
first_seen: number;
updated_at: number;
}
/**
* Insert a mint if it is new. Never touches an existing row: cached metadata is the
* whole point of the table, and discovery must not overwrite what a probe learned.
* Returns the normalized URL when a row was created, null otherwise.
*
* Deduped on the slug, not the URL. Real data has `https://mint.minibits.cash/Bitcoin`
* and `https://mint.minibits.cash/bitcoin` announced as separate mints; they are one
* mint written two ways, and keying on the URL would split its reviews across two pages.
* The URL keeps its original path case, because a mint's path is case sensitive over
* HTTP and the lowercase spelling 404s when probed.
*
* ponytail: whichever casing is seen first wins and is the one probed. The seed list
* pins the working spelling, so in practice the correct one is always inserted first.
* If that ever stops holding, the upgrade is to probe both casings once and keep the
* one that answers.
*/
export function insertMintIfNew(rawUrl: string, now = Math.floor(Date.now() / 1000)): string | null {
const normalized = normalizeMintUrl(rawUrl);
if (!normalized) {
if (!reportedSkips.has(rawUrl)) {
reportedSkips.add(rawUrl);
log.warn('skipped mint url', { url: rawUrl.slice(0, 80) });
}
return null;
}
const result = getDb()
.prepare(
// OR IGNORE covers both unique keys, url and host, in one clause.
`INSERT OR IGNORE INTO mints (url, host, status, first_seen, updated_at)
VALUES (?, ?, 'unknown', ?, ?)`,
)
.run(normalized.url, normalized.host, now, now);
return result.changes > 0 ? normalized.url : null;
}
/** Canonical mint URL for a normalized slug, or null if no such mint is tracked. */
export function mintUrlByHost(host: string): string | null {
const row = getDb().prepare('SELECT url FROM mints WHERE host = ?').get(host) as
| { url: string }
| undefined;
return row?.url ?? null;
}
export function allMintRows(): MintRow[] {
return getDb().prepare('SELECT * FROM mints').all() as MintRow[];
}
export function mintByHost(host: string): MintRow | undefined {
return getDb().prepare('SELECT * FROM mints WHERE host = ?').get(host) as MintRow | undefined;
}
export function mintByUrl(url: string): MintRow | undefined {
return getDb().prepare('SELECT * FROM mints WHERE url = ?').get(url) as MintRow | undefined;
}
/** Resolve a mint by the pubkey it publishes in /v1/info, for reviews with only a `d` tag. */
export function mintUrlByPubkey(pubkey: string): string | null {
const row = getDb().prepare('SELECT url FROM mints WHERE pubkey = ? LIMIT 1').get(pubkey) as
| { url: string }
| undefined;
return row?.url ?? null;
}
+169
View File
@@ -0,0 +1,169 @@
import { parseNuts, type MintInfo } from '@cashumints/shared';
import { config } from './config.ts';
import { getDb, setState } from './db.ts';
import { cacheIcon } from './icons.ts';
import { log } from './log.ts';
import { allMintRows, mintByUrl, type MintRow } from './mints.ts';
export interface ProbeResult {
url: string;
ok: boolean;
latencyMs: number | null;
status: string;
changed: boolean;
}
/** 1 to 2 consecutive failures is degraded, 3 or more is offline. */
export function statusForFails(fails: number): 'online' | 'degraded' | 'offline' {
if (fails === 0) return 'online';
return fails < 3 ? 'degraded' : 'offline';
}
async function fetchInfo(url: string): Promise<{ info: MintInfo; latencyMs: number }> {
const started = Date.now();
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.probeTimeoutMs);
try {
const res = await fetch(`${url}/v1/info`, {
signal: controller.signal,
headers: { Accept: 'application/json', 'User-Agent': config.userAgent },
redirect: 'follow',
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const info = (await res.json()) as MintInfo;
if (!info || typeof info !== 'object') throw new Error('not a JSON object');
return { info, latencyMs: Date.now() - started };
} finally {
clearTimeout(timer);
}
}
/**
* Probe one mint and write the result.
*
* On failure the cached metadata columns are deliberately left untouched. That cache is
* what lets an offline mint still render its full page, which is acceptance criterion #1.
*/
export async function probeMint(row: MintRow): Promise<ProbeResult> {
const db = getDb();
const now = Math.floor(Date.now() / 1000);
try {
const { info, latencyMs } = await fetchInfo(row.url);
const nuts = parseNuts(info.nuts);
const iconFile = await cacheIcon(row, info.icon_url ?? null);
db.prepare(
`UPDATE mints SET
name = COALESCE(?, name),
description = COALESCE(?, description),
icon_url = ?,
icon_file = ?,
pubkey = COALESCE(?, pubkey),
info_json = ?,
nuts_json = ?,
version = COALESCE(?, version),
status = 'online',
consecutive_fails = 0,
last_online = ?,
last_probe = ?,
updated_at = ?
WHERE url = ?`,
).run(
info.name ?? null,
info.description ?? null,
info.icon_url ?? null,
iconFile,
info.pubkey ?? null,
JSON.stringify(info),
JSON.stringify(nuts),
info.version ?? null,
now,
now,
now,
row.url,
);
db.prepare('INSERT INTO probes (mint_url, ts, ok, latency_ms) VALUES (?, ?, 1, ?)').run(
row.url,
now,
latencyMs,
);
const changed = row.status !== 'online';
if (changed) log.info('mint state change', { url: row.url, from: row.status, to: 'online' });
return { url: row.url, ok: true, latencyMs, status: 'online', changed };
} catch (err) {
const fails = row.consecutive_fails + 1;
const status = statusForFails(fails);
db.prepare(
`UPDATE mints SET consecutive_fails = ?, status = ?, last_probe = ?, updated_at = ?
WHERE url = ?`,
).run(fails, status, now, now, row.url);
db.prepare('INSERT INTO probes (mint_url, ts, ok, latency_ms) VALUES (?, ?, 0, NULL)').run(
row.url,
now,
);
const changed = row.status !== status;
if (changed) {
log.info('mint state change', {
url: row.url,
from: row.status,
to: status,
fails,
reason: err instanceof Error ? err.message : String(err),
});
}
return { url: row.url, ok: false, latencyMs: null, status, changed };
}
}
/** Run `tasks` with at most `limit` in flight. */
async function pooled<T>(items: T[], limit: number, fn: (item: T) => Promise<unknown>): Promise<void> {
let cursor = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (cursor < items.length) {
const item = items[cursor++];
if (item !== undefined) await fn(item);
}
});
await Promise.all(workers);
}
export async function probeAll(rows: MintRow[] = allMintRows()): Promise<ProbeResult[]> {
if (rows.length === 0) return [];
const started = Date.now();
const results: ProbeResult[] = [];
await pooled(rows, config.probeConcurrency, async (row) => {
results.push(await probeMint(row));
});
const online = results.filter((r) => r.ok).length;
const changed = results.filter((r) => r.changed).length;
log.info('probe cycle', {
mints: results.length,
online,
down: results.length - online,
changed,
ms: Date.now() - started,
});
setState('last_probe_at', String(Math.floor(Date.now() / 1000)));
return results;
}
/** Probe a set of URLs immediately, used when discovery finds new mints. */
export async function probeUrls(urls: string[]): Promise<void> {
const rows = urls.map((u) => mintByUrl(u)).filter((r): r is MintRow => r !== undefined);
if (rows.length > 0) await probeAll(rows);
}
+275
View File
@@ -0,0 +1,275 @@
import {
bayesianScore,
compareMints,
NEUTRAL_PRIOR_MEAN,
parseNuts,
type Health,
type MintDetail,
type MintInfo,
type MintListItem,
type MintStatus,
type ProbeSample,
type RatingDistribution,
type Stats,
} from '@cashumints/shared';
import { config, startedAt } from './config.ts';
import { getDb, getStateNumber, getState } from './db.ts';
import { mintByHost, type MintRow } from './mints.ts';
/**
* One review per author per mint, newest wins.
*
* Kind 38000 is in the addressable range, so a later event from the same author
* replaces the earlier one. The old site applied the same rule in memory
* (aggregateReviews, keyed by pubkey); doing it in SQL keeps counts honest and stops
* one npub from moving a mint's average by re-posting.
*/
const LATEST_REVIEWS = `
SELECT mint_url, pubkey, rating, created_at FROM (
SELECT mint_url, pubkey, rating, created_at,
ROW_NUMBER() OVER (
PARTITION BY mint_url, pubkey ORDER BY created_at DESC, event_id
) AS rn
FROM reviews
) WHERE rn = 1
`;
interface AggRow {
mint_url: string;
review_count: number;
rating_avg: number | null;
last_review_at: number | null;
}
function aggregates(): Map<string, AggRow> {
const rows = getDb()
.prepare(
`WITH latest AS (${LATEST_REVIEWS})
SELECT mint_url,
COUNT(*) AS review_count,
AVG(rating) AS rating_avg,
MAX(created_at) AS last_review_at
FROM latest GROUP BY mint_url`,
)
.all() as AggRow[];
return new Map(rows.map((r) => [r.mint_url, r]));
}
/**
* C in the Bayesian formula.
*
* Defaults to a neutral 3, not the observed global mean: see the long comment in
* shared/src/score.ts for why the observed mean (about 4.7 here) makes the formula
* fail its own stated purpose. `SCORE_PRIOR_MEAN=global` restores literal BACKEND.md
* behaviour, or set any number to pin it.
*/
function priorMean(): number {
const override = process.env['SCORE_PRIOR_MEAN'];
if (override && override !== 'global') {
const n = Number.parseFloat(override);
if (Number.isFinite(n) && n >= 1 && n <= 5) return n;
}
if (override === 'global') {
const row = getDb()
.prepare(`WITH latest AS (${LATEST_REVIEWS}) SELECT AVG(rating) AS avg FROM latest`)
.get() as { avg: number | null };
return row.avg ?? NEUTRAL_PRIOR_MEAN;
}
return NEUTRAL_PRIOR_MEAN;
}
function iconPath(row: MintRow): string | null {
return row.icon_file ? `/icons/${row.icon_file}` : null;
}
function round1(n: number | null): number | null {
return n === null ? null : Math.round(n * 10) / 10;
}
function toListItem(row: MintRow, agg: AggRow | undefined, mean: number, now: number): MintListItem {
const base = {
review_count: agg?.review_count ?? 0,
rating_avg: agg?.rating_avg ?? null,
status: row.status,
last_review_at: agg?.last_review_at ?? null,
};
return {
url: row.url,
host: row.host,
name: row.name,
icon: iconPath(row),
status: row.status as MintStatus,
last_online: row.last_online,
review_count: base.review_count,
rating_avg: round1(base.rating_avg),
score: bayesianScore(base, mean, now),
last_review_at: base.last_review_at,
version: row.version,
};
}
export function listMints(limit?: number): MintListItem[] {
const now = Math.floor(Date.now() / 1000);
const agg = aggregates();
const mean = priorMean();
const items = (getDb().prepare('SELECT * FROM mints').all() as MintRow[])
.map((row) => toListItem(row, agg.get(row.url), mean, now))
.sort(compareMints);
return limit && limit > 0 ? items.slice(0, limit) : items;
}
function distribution(mintUrl: string): RatingDistribution {
const rows = getDb()
.prepare(
`WITH latest AS (${LATEST_REVIEWS})
SELECT rating, COUNT(*) AS n FROM latest
WHERE mint_url = ? AND rating IS NOT NULL GROUP BY rating`,
)
.all(mintUrl) as { rating: number; n: number }[];
const dist: RatingDistribution = { '1': 0, '2': 0, '3': 0, '4': 0, '5': 0 };
for (const r of rows) {
const key = String(r.rating) as keyof RatingDistribution;
if (key in dist) dist[key] = r.n;
}
return dist;
}
function reviews90d(mintUrl: string): number {
const cutoff = Math.floor(Date.now() / 1000) - 90 * 24 * 60 * 60;
const row = getDb()
.prepare(
`WITH latest AS (${LATEST_REVIEWS})
SELECT COUNT(*) AS n FROM latest WHERE mint_url = ? AND created_at >= ?`,
)
.get(mintUrl, cutoff) as { n: number };
return row.n;
}
function uptime30d(mintUrl: string): number | null {
const cutoff = Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60;
const row = getDb()
.prepare('SELECT AVG(ok) AS up, COUNT(*) AS n FROM probes WHERE mint_url = ? AND ts >= ?')
.get(mintUrl, cutoff) as { up: number | null; n: number };
if (!row.n || row.up === null) return null;
return Math.round(row.up * 1000) / 1000;
}
function recentProbes(mintUrl: string): ProbeSample[] {
const cutoff = Math.floor(Date.now() / 1000) - 48 * 60 * 60;
return getDb()
.prepare(
'SELECT ts, ok, latency_ms FROM probes WHERE mint_url = ? AND ts >= ? ORDER BY ts ASC',
)
.all(mintUrl, cutoff) as ProbeSample[];
}
function parseInfo(json: string | null): MintInfo | null {
if (!json) return null;
try {
return JSON.parse(json) as MintInfo;
} catch {
return null;
}
}
export function getMintDetail(host: string): MintDetail | null {
const row = mintByHost(host);
if (!row) return null;
const now = Math.floor(Date.now() / 1000);
const agg = aggregates().get(row.url);
const item = toListItem(row, agg, priorMean(), now);
const info = parseInfo(row.info_json);
let nuts: string[] = [];
if (row.nuts_json) {
try {
nuts = JSON.parse(row.nuts_json) as string[];
} catch {
nuts = [];
}
}
if (nuts.length === 0 && info) nuts = parseNuts(info.nuts);
return {
...item,
description: row.description,
pubkey: row.pubkey,
info,
nuts,
first_seen: row.first_seen,
last_probe: row.last_probe,
updated_at: row.updated_at,
rating_distribution: distribution(row.url),
reviews_90d: reviews90d(row.url),
uptime_30d: uptime30d(row.url),
probes_recent: recentProbes(row.url),
};
}
let statsCache: { at: number; value: Stats } | null = null;
const STATS_TTL_S = 60;
export function getStats(): Stats {
const now = Math.floor(Date.now() / 1000);
if (statsCache && now - statsCache.at < STATS_TTL_S) return statsCache.value;
const db = getDb();
const counts = db
.prepare(
`SELECT
COUNT(*) AS total,
SUM(status = 'online') AS online,
SUM(status = 'offline') AS offline,
SUM(status = 'degraded') AS degraded
FROM mints`,
)
.get() as { total: number; online: number | null; offline: number | null; degraded: number | null };
const reviews = db
.prepare(`WITH latest AS (${LATEST_REVIEWS}) SELECT COUNT(*) AS n, MAX(created_at) AS last FROM latest`)
.get() as { n: number; last: number | null };
const value: Stats = {
mints_total: counts.total,
mints_online: counts.online ?? 0,
mints_offline: counts.offline ?? 0,
mints_degraded: counts.degraded ?? 0,
reviews_total: reviews.n,
last_review_at: reviews.last,
updated_at: now,
};
statsCache = { at: now, value };
return value;
}
/** Health bypasses the stats cache: it is the endpoint you page on. */
export function getHealth(): Health {
const now = Math.floor(Date.now() / 1000);
const lastProbe = getStateNumber('last_probe_at');
const lastDiscovery = getStateNumber('last_discovery_at');
const discoveryOk = getState('last_discovery_ok') !== '0';
const tracked = (getDb().prepare('SELECT COUNT(*) AS n FROM mints').get() as { n: number }).n;
const staleAfter = config.probeIntervalMin * 60 * 3;
const probeStale = lastProbe === null || now - lastProbe > staleAfter;
return {
status: probeStale || !discoveryOk ? 'degraded' : 'ok',
uptime_s: now - startedAt,
last_probe_at: lastProbe,
last_discovery_at: lastDiscovery,
mints_tracked: tracked,
updated_at: now,
};
}
+120
View File
@@ -0,0 +1,120 @@
/**
* pnpm seed: ingest the initial mint list, run one probe cycle, run one discovery
* backfill, print a summary table.
*
* The list below is every mint URL carried in a kind 38172 announcement on the old
* site's relay pool at the time of the rebuild (see NOTES.md: the old project has no
* hardcoded list of its own, its directory is built live from Nostr). Seeding is only
* a warm start, discovery finds everything here on its own and keeps finding more.
*/
import { closeDb, getDb } from './db.ts';
import { closePool, runDiscovery } from './discovery.ts';
import { log } from './log.ts';
import { insertMintIfNew } from './mints.ts';
import { probeAll } from './probe.ts';
import { listMints } from './queries.ts';
const SEED_MINTS = [
'https://21mint.me',
'https://bitcointxoko.com/cashu/api/v1/dMk78c5aR7uhHzcqH3Bwqp',
'https://cashu.boats',
'https://cashu.mineracks.com',
'https://forge.flashapp.me',
'https://lnb.bolverker.com/cashu/api/v1/GZjPXyQXsgnZEgKZsLRQyy',
'https://lnwallet.app/cashu/api/v1/9tZLEVyknrtaGxeBjEecc4',
'https://bb.mountainlake.io/cashu/api/v1/WpczZwvNES7wMa8ForKGmh',
'https://mint.0xchat.com',
'https://mint.28waves.com',
'https://mint.belgianbitcoinembassy.org',
'https://mint.btcforplebs.com',
'https://mint.cashu.chat',
'https://mint.coinos.io',
'https://mint.endfiat.money',
'https://mint.fredix.xyz',
'https://mint.gitvid.net',
'https://mint.gwoq.com',
'https://mint.kashir.xyz',
'https://mint.lnfast.com',
'https://mint.lnserver.com',
'https://mint.lnvoltz.com',
'https://mint.lnw.cash',
'https://mint.lnwallet.app',
'https://mint.mikelcalvo.net',
'https://mint.minibits.cash/Bitcoin',
'https://mint.mineracks.com',
'https://mint.mountainlake.io',
'https://mint.nostrich.cc',
'https://mint.quixote.cash/cashu/api/v1/TX6nZECe8r6hjDxtdA6N5b',
'https://mint.sharegap.net',
'https://mint.westernbtc.com',
'https://mint2.nutmix.cash',
'https://obsessedjerky7.lnbits.com/cashu/api/v1/L4bNfiBtTyhEpZWdt6QTb4',
'https://smilemoji.cash:3338',
'https://stpaulinternet.net/cashu/api/v1/TTcmiEDSWmkefef3XqERKv',
'https://kashu.me',
];
function pad(s: string, width: number): string {
return s.length > width ? `${s.slice(0, width - 1)}…` : s.padEnd(width);
}
async function main(): Promise<void> {
getDb();
let added = 0;
for (const url of SEED_MINTS) if (insertMintIfNew(url)) added++;
log.info('seed list ingested', { listed: SEED_MINTS.length, added });
await probeAll();
const discovery = await runDiscovery(true);
// Discovery almost always turns up mints the seed list did not have. Probe those too,
// otherwise they sit at status 'unknown' with no metadata.
if (discovery.newMints.length > 0) {
log.info('probing newly discovered mints', { count: discovery.newMints.length });
await probeAll();
}
const mints = listMints();
const width = { host: 44, status: 9, score: 7, reviews: 8, rating: 7 };
console.log('');
console.log(
[
pad('MINT', width.host),
pad('STATUS', width.status),
pad('SCORE', width.score),
pad('REVIEWS', width.reviews),
pad('RATING', width.rating),
'VERSION',
].join(' '),
);
console.log('-'.repeat(110));
for (const m of mints) {
console.log(
[
pad(m.host, width.host),
pad(m.status, width.status),
pad(m.score.toFixed(3), width.score),
pad(String(m.review_count), width.reviews),
pad(m.rating_avg === null ? '-' : m.rating_avg.toFixed(1), width.rating),
m.version ?? '-',
].join(' '),
);
}
const online = mints.filter((m) => m.status === 'online').length;
const offline = mints.filter((m) => m.status === 'offline').length;
const reviews = mints.reduce((sum, m) => sum + m.review_count, 0);
console.log('-'.repeat(110));
console.log(
`${mints.length} mints (${online} online, ${offline} offline), ${reviews} reviews indexed`,
);
closePool();
closeDb();
process.exit(0);
}
void main();
+48
View File
@@ -0,0 +1,48 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { serveStatic } from '@hono/node-server/serve-static';
import path from 'node:path';
import { config } from './config.ts';
import { getHealth, getMintDetail, getStats, listMints } from './queries.ts';
export function createApp(): Hono {
const app = new Hono();
// Public read-only API. There is nothing to protect and every client is a browser.
app.use('/api/*', cors());
app.use('/icons/*', cors());
app.get('/api/health', (c) => {
const health = getHealth();
c.header('Cache-Control', 'no-store');
return c.json(health, health.status === 'ok' ? 200 : 503);
});
app.get('/api/stats', (c) => c.json(getStats()));
app.get('/api/mints', (c) => {
const raw = c.req.query('limit');
const limit = raw ? Number.parseInt(raw, 10) : undefined;
return c.json(listMints(Number.isFinite(limit) ? limit : undefined));
});
app.get('/api/mints/:host', (c) => {
const detail = getMintDetail(c.req.param('host'));
if (!detail) return c.json({ error: 'not_found', message: 'No mint with that host' }, 404);
return c.json(detail);
});
// Cached mint icons, so an offline mint keeps its icon.
app.use(
'/icons/*',
serveStatic({
root: path.relative(process.cwd(), config.iconDir) || '.',
rewriteRequestPath: (p) => p.replace(/^\/icons/, ''),
onFound: (_p, c) => c.header('Cache-Control', 'public, max-age=86400'),
}),
);
app.notFound((c) => c.json({ error: 'not_found' }, 404));
return app;
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2023"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"skipLibCheck": true,
"types": ["node"],
"resolveJsonModule": true
},
"include": ["src/**/*.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "cashumints",
"private": true,
"version": "2.0.0",
"type": "module",
"engines": {
"node": ">=20"
},
"scripts": {
"dev": "pnpm --parallel --filter ./api --filter ./web dev",
"dev:api": "pnpm --filter ./api dev",
"dev:web": "pnpm --filter ./web dev",
"seed": "pnpm --filter ./api seed",
"bones": "pnpm --filter ./web bones",
"build": "pnpm --filter ./shared build && pnpm --filter ./web build",
"check:links": "pnpm --filter ./web check:links",
"typecheck": "pnpm -r typecheck",
"check:i18n": "pnpm --filter ./web check:i18n",
"check:hreflang": "pnpm --filter ./web check:hreflang"
}
}
+4519
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
packages:
- 'api'
- 'web'
- 'shared'
+90
View File
@@ -0,0 +1,90 @@
{
"note": "Two failed probes in a row. Not offline, so no offline banner, and the config is still current.",
"status": "degraded",
"last_online": 1786998200,
"first_seen": 1761080000,
"info": {
"version": "Nutshell/0.20.3",
"nuts": {
"4": {
"methods": [
{
"method": "bolt11",
"unit": "sat",
"method_name": "bolt11",
"min_amount": 0,
"max_amount": 50000,
"options": {
"description": true
}
}
],
"disabled": false
},
"5": {
"methods": [
{
"method": "bolt11",
"unit": "sat",
"method_name": "bolt11",
"min_amount": 0,
"max_amount": 50000
}
],
"disabled": false
},
"7": {
"supported": true
},
"8": {
"supported": true
},
"9": {
"supported": true
},
"10": {
"supported": true
},
"11": {
"supported": true
},
"12": {
"supported": true
},
"14": {
"supported": true
},
"15": {
"methods": [
{
"method": "bolt11",
"unit": "sat"
}
]
},
"17": {
"supported": [
{
"method": "bolt11",
"unit": "sat",
"commands": [
"bolt11_melt_quote",
"proof_state",
"bolt11_mint_quote"
]
}
]
},
"20": {
"supported": true
},
"29": {
"supported": true,
"max_batch_size": 1000,
"methods": [
"bolt11"
]
}
}
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"note": "lekmint.cash/api as indexed on 2026-08-20: both NUTs disabled true with empty methods arrays.",
"status": "online",
"last_online": 1786999910,
"first_seen": 1784408000,
"info": {
"version": "LekMint/1.1.1",
"nuts": {
"4": {
"methods": [],
"disabled": true
},
"5": {
"methods": [],
"disabled": true
},
"7": {
"supported": true
},
"8": {
"supported": false
},
"9": {
"supported": false
},
"10": {
"supported": false
},
"11": {
"supported": false
},
"12": {
"supported": true
}
}
}
}
+136
View File
@@ -0,0 +1,136 @@
{
"note": "mint.minibits.cash/Bitcoin, cdk-mintd 0.17.5, bolt11 plus onchain methods, both NUTs enabled.",
"status": "online",
"last_online": 1786999880,
"first_seen": 1752440000,
"info": {
"version": "cdk-mintd/0.17.5",
"nuts": {
"4": {
"methods": [
{
"method": "bolt11",
"unit": "sat",
"min_amount": 1,
"max_amount": 1000000,
"description": true
},
{
"method": "onchain",
"unit": "sat",
"min_amount": 10000,
"max_amount": 1000000,
"options": {
"confirmations": 3
}
}
],
"disabled": false
},
"5": {
"methods": [
{
"method": "bolt11",
"unit": "sat",
"min_amount": 1,
"max_amount": 1000000,
"amountless": true
},
{
"method": "onchain",
"unit": "sat",
"min_amount": 10000,
"max_amount": 1000000
}
],
"disabled": false
},
"7": {
"supported": true
},
"8": {
"supported": true
},
"9": {
"supported": true
},
"10": {
"supported": true
},
"11": {
"supported": true
},
"12": {
"supported": true
},
"14": {
"supported": true
},
"15": {
"methods": [
{
"method": "bolt11",
"unit": "sat"
}
]
},
"17": {
"supported": [
{
"method": "bolt11",
"unit": "sat",
"commands": [
"bolt11_mint_quote",
"bolt11_melt_quote",
"proof_state"
]
},
{
"method": "onchain",
"unit": "sat",
"commands": [
"onchain_mint_quote",
"onchain_melt_quote",
"proof_state"
]
}
]
},
"19": {
"ttl": 3600,
"cached_endpoints": [
{
"method": "POST",
"path": "/v1/swap"
},
{
"method": "POST",
"path": "/v1/mint/bolt11"
},
{
"method": "POST",
"path": "/v1/melt/bolt11"
},
{
"method": "POST",
"path": "/v1/mint/onchain"
},
{
"method": "POST",
"path": "/v1/melt/onchain"
}
]
},
"20": {
"supported": true
},
"29": {
"max_batch_size": 100,
"methods": [
"bolt11",
"onchain"
]
}
}
}
}
@@ -0,0 +1,90 @@
{
"note": "mint.macadamia.cash, Nutshell 0.20.3, single bolt11 method per NUT.",
"status": "online",
"last_online": 1786999940,
"first_seen": 1761080000,
"info": {
"version": "Nutshell/0.20.3",
"nuts": {
"4": {
"methods": [
{
"method": "bolt11",
"unit": "sat",
"method_name": "bolt11",
"min_amount": 0,
"max_amount": 50000,
"options": {
"description": true
}
}
],
"disabled": false
},
"5": {
"methods": [
{
"method": "bolt11",
"unit": "sat",
"method_name": "bolt11",
"min_amount": 0,
"max_amount": 50000
}
],
"disabled": false
},
"7": {
"supported": true
},
"8": {
"supported": true
},
"9": {
"supported": true
},
"10": {
"supported": true
},
"11": {
"supported": true
},
"12": {
"supported": true
},
"14": {
"supported": true
},
"15": {
"methods": [
{
"method": "bolt11",
"unit": "sat"
}
]
},
"17": {
"supported": [
{
"method": "bolt11",
"unit": "sat",
"commands": [
"bolt11_melt_quote",
"proof_state",
"bolt11_mint_quote"
]
}
]
},
"20": {
"supported": true
},
"29": {
"supported": true,
"max_batch_size": 1000,
"methods": [
"bolt11"
]
}
}
}
}
@@ -0,0 +1,90 @@
{
"note": "Synthetic: mint.macadamia.cash payload with NUT-05 disabled flipped to true. No mint indexed on 2026-08-20 published this state.",
"status": "online",
"last_online": 1786999910,
"first_seen": 1769720000,
"info": {
"version": "Nutshell/0.20.3",
"nuts": {
"4": {
"methods": [
{
"method": "bolt11",
"unit": "sat",
"method_name": "bolt11",
"min_amount": 0,
"max_amount": 50000,
"options": {
"description": true
}
}
],
"disabled": false
},
"5": {
"methods": [
{
"method": "bolt11",
"unit": "sat",
"method_name": "bolt11",
"min_amount": 0,
"max_amount": 50000
}
],
"disabled": true
},
"7": {
"supported": true
},
"8": {
"supported": true
},
"9": {
"supported": true
},
"10": {
"supported": true
},
"11": {
"supported": true
},
"12": {
"supported": true
},
"14": {
"supported": true
},
"15": {
"methods": [
{
"method": "bolt11",
"unit": "sat"
}
]
},
"17": {
"supported": [
{
"method": "bolt11",
"unit": "sat",
"commands": [
"bolt11_melt_quote",
"proof_state",
"bolt11_mint_quote"
]
}
]
},
"20": {
"supported": true
},
"29": {
"supported": true,
"max_batch_size": 1000,
"methods": [
"bolt11"
]
}
}
}
}
+73
View File
@@ -0,0 +1,73 @@
{
"note": "mint.belgianbitcoinembassy.org as indexed on 2026-08-20: NUT-04 disabled true, NUT-05 enabled.",
"status": "online",
"last_online": 1786999910,
"first_seen": 1743800000,
"info": {
"version": "Nutshell/0.18.2",
"nuts": {
"4": {
"methods": [
{
"method": "bolt11",
"unit": "sat",
"min_amount": 0,
"max_amount": 100000,
"options": {
"description": true
}
}
],
"disabled": true
},
"5": {
"methods": [
{
"method": "bolt11",
"unit": "sat",
"min_amount": 0,
"max_amount": 100000
}
],
"disabled": false
},
"7": {
"supported": true
},
"8": {
"supported": true
},
"9": {
"supported": true
},
"10": {
"supported": true
},
"11": {
"supported": true
},
"12": {
"supported": true
},
"14": {
"supported": true
},
"17": {
"supported": [
{
"method": "bolt11",
"unit": "sat",
"commands": [
"bolt11_melt_quote",
"proof_state",
"bolt11_mint_quote"
]
}
]
},
"20": {
"supported": true
}
}
}
}
@@ -0,0 +1,86 @@
{
"note": "Synthetic: NUT-04 enabled but every method is onchain, so no sats can be minted over Lightning.",
"status": "online",
"last_online": 1786999910,
"first_seen": 1769720000,
"info": {
"version": "Nutshell/0.20.3",
"nuts": {
"4": {
"methods": [
{
"method": "onchain",
"unit": "sat",
"min_amount": 10000,
"max_amount": 1000000
}
],
"disabled": false
},
"5": {
"methods": [
{
"method": "bolt11",
"unit": "sat",
"method_name": "bolt11",
"min_amount": 0,
"max_amount": 50000
}
],
"disabled": false
},
"7": {
"supported": true
},
"8": {
"supported": true
},
"9": {
"supported": true
},
"10": {
"supported": true
},
"11": {
"supported": true
},
"12": {
"supported": true
},
"14": {
"supported": true
},
"15": {
"methods": [
{
"method": "bolt11",
"unit": "sat"
}
]
},
"17": {
"supported": [
{
"method": "bolt11",
"unit": "sat",
"commands": [
"bolt11_melt_quote",
"proof_state",
"bolt11_mint_quote"
]
}
]
},
"20": {
"supported": true
},
"29": {
"supported": true,
"max_batch_size": 1000,
"methods": [
"bolt11"
]
}
}
}
}
@@ -0,0 +1,63 @@
{
"note": "Synthetic: a mint that publishes neither NUT-04 nor NUT-05. Not published is not the same as disabled, so no banner.",
"status": "online",
"last_online": 1786999910,
"first_seen": 1769720000,
"info": {
"version": "Nutshell/0.20.3",
"nuts": {
"7": {
"supported": true
},
"8": {
"supported": true
},
"9": {
"supported": true
},
"10": {
"supported": true
},
"11": {
"supported": true
},
"12": {
"supported": true
},
"14": {
"supported": true
},
"15": {
"methods": [
{
"method": "bolt11",
"unit": "sat"
}
]
},
"17": {
"supported": [
{
"method": "bolt11",
"unit": "sat",
"commands": [
"bolt11_melt_quote",
"proof_state",
"bolt11_mint_quote"
]
}
]
},
"20": {
"supported": true
},
"29": {
"supported": true,
"max_batch_size": 1000,
"methods": [
"bolt11"
]
}
}
}
}
@@ -0,0 +1,90 @@
{
"note": "Offline 64 days and NUT-05 was disabled when last seen: the combined case.",
"status": "offline",
"last_online": 1781470400,
"first_seen": 1761080000,
"info": {
"version": "Nutshell/0.20.3",
"nuts": {
"4": {
"methods": [
{
"method": "bolt11",
"unit": "sat",
"method_name": "bolt11",
"min_amount": 0,
"max_amount": 50000,
"options": {
"description": true
}
}
],
"disabled": false
},
"5": {
"methods": [
{
"method": "bolt11",
"unit": "sat",
"method_name": "bolt11",
"min_amount": 0,
"max_amount": 50000
}
],
"disabled": true
},
"7": {
"supported": true
},
"8": {
"supported": true
},
"9": {
"supported": true
},
"10": {
"supported": true
},
"11": {
"supported": true
},
"12": {
"supported": true
},
"14": {
"supported": true
},
"15": {
"methods": [
{
"method": "bolt11",
"unit": "sat"
}
]
},
"17": {
"supported": [
{
"method": "bolt11",
"unit": "sat",
"commands": [
"bolt11_melt_quote",
"proof_state",
"bolt11_mint_quote"
]
}
]
},
"20": {
"supported": true
},
"29": {
"supported": true,
"max_batch_size": 1000,
"methods": [
"bolt11"
]
}
}
}
}
@@ -0,0 +1,90 @@
{
"note": "Healthy config, offline for 64 days: tier 3.",
"status": "offline",
"last_online": 1781470400,
"first_seen": 1761080000,
"info": {
"version": "Nutshell/0.20.3",
"nuts": {
"4": {
"methods": [
{
"method": "bolt11",
"unit": "sat",
"method_name": "bolt11",
"min_amount": 0,
"max_amount": 50000,
"options": {
"description": true
}
}
],
"disabled": false
},
"5": {
"methods": [
{
"method": "bolt11",
"unit": "sat",
"method_name": "bolt11",
"min_amount": 0,
"max_amount": 50000
}
],
"disabled": false
},
"7": {
"supported": true
},
"8": {
"supported": true
},
"9": {
"supported": true
},
"10": {
"supported": true
},
"11": {
"supported": true
},
"12": {
"supported": true
},
"14": {
"supported": true
},
"15": {
"methods": [
{
"method": "bolt11",
"unit": "sat"
}
]
},
"17": {
"supported": [
{
"method": "bolt11",
"unit": "sat",
"commands": [
"bolt11_melt_quote",
"proof_state",
"bolt11_mint_quote"
]
}
]
},
"20": {
"supported": true
},
"29": {
"supported": true,
"max_batch_size": 1000,
"methods": [
"bolt11"
]
}
}
}
}
@@ -0,0 +1,73 @@
{
"note": "Offline 12 days and NUT-04 was disabled when last seen: the critical offline banner outranks the melt-only warning.",
"status": "offline",
"last_online": 1785963200,
"first_seen": 1761080000,
"info": {
"version": "Nutshell/0.20.3",
"nuts": {
"4": {
"methods": [
{
"method": "bolt11",
"unit": "sat",
"min_amount": 0,
"max_amount": 100000,
"options": {
"description": true
}
}
],
"disabled": true
},
"5": {
"methods": [
{
"method": "bolt11",
"unit": "sat",
"min_amount": 0,
"max_amount": 100000
}
],
"disabled": false
},
"7": {
"supported": true
},
"8": {
"supported": true
},
"9": {
"supported": true
},
"10": {
"supported": true
},
"11": {
"supported": true
},
"12": {
"supported": true
},
"14": {
"supported": true
},
"17": {
"supported": [
{
"method": "bolt11",
"unit": "sat",
"commands": [
"bolt11_melt_quote",
"proof_state",
"bolt11_mint_quote"
]
}
]
},
"20": {
"supported": true
}
}
}
}
@@ -0,0 +1,90 @@
{
"note": "Healthy config, offline for 12 days: tier 2.",
"status": "offline",
"last_online": 1785963200,
"first_seen": 1761080000,
"info": {
"version": "Nutshell/0.20.3",
"nuts": {
"4": {
"methods": [
{
"method": "bolt11",
"unit": "sat",
"method_name": "bolt11",
"min_amount": 0,
"max_amount": 50000,
"options": {
"description": true
}
}
],
"disabled": false
},
"5": {
"methods": [
{
"method": "bolt11",
"unit": "sat",
"method_name": "bolt11",
"min_amount": 0,
"max_amount": 50000
}
],
"disabled": false
},
"7": {
"supported": true
},
"8": {
"supported": true
},
"9": {
"supported": true
},
"10": {
"supported": true
},
"11": {
"supported": true
},
"12": {
"supported": true
},
"14": {
"supported": true
},
"15": {
"methods": [
{
"method": "bolt11",
"unit": "sat"
}
]
},
"17": {
"supported": [
{
"method": "bolt11",
"unit": "sat",
"commands": [
"bolt11_melt_quote",
"proof_state",
"bolt11_mint_quote"
]
}
]
},
"20": {
"supported": true
},
"29": {
"supported": true,
"max_batch_size": 1000,
"methods": [
"bolt11"
]
}
}
}
}
@@ -0,0 +1,7 @@
{
"note": "mint.azzamo.net shape: offline with no last_online and no cached info at all.",
"status": "offline",
"last_online": null,
"first_seen": 1786222400,
"info": null
}
@@ -0,0 +1,90 @@
{
"note": "Offline 3 days and NUT-05 was disabled when last seen: the critical capability banner outranks the tier 1 offline one.",
"status": "offline",
"last_online": 1786740800,
"first_seen": 1761080000,
"info": {
"version": "Nutshell/0.20.3",
"nuts": {
"4": {
"methods": [
{
"method": "bolt11",
"unit": "sat",
"method_name": "bolt11",
"min_amount": 0,
"max_amount": 50000,
"options": {
"description": true
}
}
],
"disabled": false
},
"5": {
"methods": [
{
"method": "bolt11",
"unit": "sat",
"method_name": "bolt11",
"min_amount": 0,
"max_amount": 50000
}
],
"disabled": true
},
"7": {
"supported": true
},
"8": {
"supported": true
},
"9": {
"supported": true
},
"10": {
"supported": true
},
"11": {
"supported": true
},
"12": {
"supported": true
},
"14": {
"supported": true
},
"15": {
"methods": [
{
"method": "bolt11",
"unit": "sat"
}
]
},
"17": {
"supported": [
{
"method": "bolt11",
"unit": "sat",
"commands": [
"bolt11_melt_quote",
"proof_state",
"bolt11_mint_quote"
]
}
]
},
"20": {
"supported": true
},
"29": {
"supported": true,
"max_batch_size": 1000,
"methods": [
"bolt11"
]
}
}
}
}
@@ -0,0 +1,90 @@
{
"note": "Healthy config, offline for 3 days: tier 1.",
"status": "offline",
"last_online": 1786740800,
"first_seen": 1761080000,
"info": {
"version": "Nutshell/0.20.3",
"nuts": {
"4": {
"methods": [
{
"method": "bolt11",
"unit": "sat",
"method_name": "bolt11",
"min_amount": 0,
"max_amount": 50000,
"options": {
"description": true
}
}
],
"disabled": false
},
"5": {
"methods": [
{
"method": "bolt11",
"unit": "sat",
"method_name": "bolt11",
"min_amount": 0,
"max_amount": 50000
}
],
"disabled": false
},
"7": {
"supported": true
},
"8": {
"supported": true
},
"9": {
"supported": true
},
"10": {
"supported": true
},
"11": {
"supported": true
},
"12": {
"supported": true
},
"14": {
"supported": true
},
"15": {
"methods": [
{
"method": "bolt11",
"unit": "sat"
}
]
},
"17": {
"supported": [
{
"method": "bolt11",
"unit": "sat",
"commands": [
"bolt11_melt_quote",
"proof_state",
"bolt11_mint_quote"
]
}
]
},
"20": {
"supported": true
},
"29": {
"supported": true,
"max_batch_size": 1000,
"methods": [
"bolt11"
]
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@cashumints/shared",
"version": "2.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": { ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" } },
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": { "typescript": "^5.6.3" }
}
+6
View File
@@ -0,0 +1,6 @@
export * from './types.js';
export * from './nostr.js';
export * from './normalize.js';
export * from './score.js';
export * from './nuts.js';
export * from './warnings.js';
+101
View File
@@ -0,0 +1,101 @@
/**
* One mint URL normalizer, used by the indexer, the API and the browser.
*
* The old site had three different ad-hoc normalizers that disagreed (see NOTES.md):
* mints were keyed on the raw tag string (so a trailing slash created a duplicate mint)
* while reviews were matched on bare hostname (so a review of one path counted for every
* path on that host). Rules below, per BACKEND.md:
*
* - lowercase scheme and host, force https, strip trailing slash, strip default port
* - keep the path: mint.minibits.cash/Bitcoin is a different mint from mint.minibits.cash
* - reject non-https-able, localhost, private IPs and .onion
*/
export interface NormalizedMint {
/** Canonical URL, the primary key. */
url: string;
/** URL-safe routing slug, unique per url. */
host: string;
}
const PRIVATE_IPV4 =
/^(10\.|127\.|0\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.)/;
/** Hostnames that are never a public mint. */
function isDisallowedHost(hostname: string): boolean {
if (hostname === 'localhost' || hostname.endsWith('.localhost')) return true;
if (hostname.endsWith('.onion')) return true;
if (hostname.endsWith('.local')) return true;
if (hostname === '::1' || hostname === '[::1]') return true;
if (PRIVATE_IPV4.test(hostname)) return true;
// A bare label with no dot cannot be a public host.
if (!hostname.includes('.') && !hostname.includes(':')) return true;
return false;
}
/**
* Normalize a mint URL as found in a Nostr `u` tag or typed by a user.
* Returns null when the input is not a usable public mint URL.
*/
export function normalizeMintUrl(input: string): NormalizedMint | null {
const raw = input?.trim();
if (!raw) return null;
// Tolerate a missing scheme ("mint.example.com/x"), which users paste constantly.
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`;
let u: URL;
try {
u = new URL(withScheme);
} catch {
return null;
}
// http:// and https:// are the only schemes a mint speaks; http is upgraded, not kept,
// because the browser cannot fetch http from an https page anyway (mixed content).
if (u.protocol !== 'https:' && u.protocol !== 'http:') return null;
u.protocol = 'https:';
const hostname = u.hostname.toLowerCase();
if (!hostname || isDisallowedHost(hostname)) return null;
// Drop the default port, query and fragment: none of them identify a mint.
const port = u.port === '443' || u.port === '80' ? '' : u.port;
const path = u.pathname.replace(/\/+$/, '');
const url = `https://${hostname}${port ? `:${port}` : ''}${path}`;
return { url, host: slugFor(hostname, port, path) };
}
/**
* Routing slug. Hostname alone when there is no path, otherwise hostname plus the path
* segments joined with `-`. Deterministic; the row is looked up by slug, so it does not
* need to be reversible.
*/
function slugFor(hostname: string, port: string, path: string): string {
const segments = path.split('/').filter(Boolean);
const base = port ? `${hostname}-${port}` : hostname;
if (segments.length === 0) return base;
const tail = segments
.join('-')
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
return tail ? `${base}-${tail}` : base;
}
/** Display domain for a mint: hostname plus path, no scheme. */
export function displayDomain(url: string): string {
return url.replace(/^https?:\/\//, '').replace(/\/+$/, '');
}
/**
* Short display name derived from the URL, for mints whose /v1/info has no name.
* Matches the old site's `hostname.replace(/^mint\./,'').replace(/^www\./,'')`.
*/
export function nameFromUrl(url: string): string {
const domain = displayDomain(url);
const host = domain.split('/')[0] ?? domain;
return host.replace(/^mint\./, '').replace(/^www\./, '');
}
+313
View File
@@ -0,0 +1,313 @@
/**
* NIP-87 constants and parse rules.
*
* Kinds, tags, relays and the rating encoding are taken verbatim from the old site
* (see NOTES.md) so the new site sees exactly the same events. Do not "fix" these
* to match a spec reading: what matters is what is actually on the relays.
*/
import type { Profile } from './types.js';
/** NIP-87 mint recommendation / review. */
export const KIND_REVIEW = 38000;
/** NIP-87 Cashu mint announcement. */
export const KIND_MINT_ANNOUNCEMENT = 38172;
/** Profile metadata. */
export const KIND_PROFILE = 0;
/**
* Union of the old site's read pool (utils/ndk.ts CASHU_RELAY_POOL) and its write pool
* (services/reviewPublisher.ts RELAY_URLS). The two lists differed, so reviews the old
* site published to snort/primal were invisible to it. Reading the union fixes that.
*
* Minus `relay.damus.io`, which was in both of the old lists and earned neither place:
* it answered a kind 38000 query with nothing at all, and it was the slowest relay in
* the pool doing it. A query closes when its slowest relay does, so a relay that holds
* none of what is being asked for costs the whole site latency for no events.
*/
export const DEFAULT_RELAYS = [
'wss://relay.cashumints.space',
'wss://nos.lol',
'wss://relay.azzamo.net',
'wss://relay.snort.social',
'wss://relay.primal.net',
] as const;
/** Relay hint used in the `a` tag, matching the old publisher's RELAY_URLS[0]. */
export const PRIMARY_RELAY = 'wss://relay.cashumints.space';
/**
* Relays queried for kind 0 metadata, on top of the review relays above.
*
* The review pool alone is not enough: `relay.cashumints.space` holds no kind 0 at
* all, and `snort.social` and `primal.net` hold almost none, so a reviewer's name
* often lives on a relay this site never asked. Measured against the 80 authors who
* have reviewed mint.minibits.cash, hit rates were:
*
* nos.lol 29, relay.nostr.net 28, azzamo 24, damus 18, purplepag.es 15,
* primal 5, snort 1, relay.cashumints.space 0
*
* 29 is the ceiling: the other 51 keys have no kind 0 on any relay tested, and no
* other events either, so they are single-purpose review keys and stay as npubs.
*
* damus is no longer in this pool, despite that 18. It came out of DEFAULT_RELAYS on
* latency and this list is built from that one, and the trade was worth taking: it
* answered a 25-author kind 0 batch in 1217ms where every other relay here answered
* in under 150ms, so it alone set the whole batch's duration. The handful of names it
* was the only source for now render as a short npub, which is the same fallback the
* other 51 keys already use.
*
* `relay.nostr.band` is deliberately NOT here. It answered author-filtered kind 0
* queries with nothing at all (even for a single well known pubkey) and took the
* full 7s to do it, which held the whole batch open until its timeout instead of
* closing on EOSE in well under a second.
*
* Override with PUBLIC_PROFILE_RELAYS (browser) or PROFILE_RELAYS (build), comma
* separated.
*/
export const PROFILE_RELAYS = [
...DEFAULT_RELAYS,
'wss://purplepag.es',
'wss://relay.nostr.net',
] as const;
/** Minimal structural view of a Nostr event, so this module needs no dependency. */
export interface NostrEventLike {
id: string;
pubkey: string;
kind: number;
created_at: number;
content: string;
tags: string[][];
}
export function tagValue(tags: string[][], name: string): string | null {
for (const t of tags) if (t[0] === name && t[1]) return t[1];
return null;
}
export function tagValues(tags: string[][], name: string): string[] {
const out: string[] = [];
for (const t of tags) if (t[0] === name && t[1]) out.push(t[1]);
return out;
}
/**
* Extract a rating from a review event.
*
* Order matches the old parser (utils/reviewHelpers.ts parseNIP87Review) with one
* deliberate change: the old code defaulted to 5 when nothing parsed, which is why
* every mint on the old site showed 5/5. Unparseable now returns null and is left
* out of averages.
*/
export function parseRating(event: NostrEventLike): number | null {
const tag = tagValue(event.tags, 'rating');
if (tag) {
const n = Number.parseInt(tag, 10);
if (n >= 1 && n <= 5) return n;
// Some clients write a 0..1 fraction instead of 1..5 (NIP-87 suggests this).
const f = Number.parseFloat(tag);
if (Number.isFinite(f) && f >= 0 && f <= 1) return Math.max(1, Math.round(f * 5));
}
// The old publisher wrote no rating tag at all, only a `[N/5]` content prefix.
// This is how the great majority of real reviews encode their rating.
const bracket = /^\s*\[([1-5])\/5\]/.exec(event.content);
if (bracket?.[1]) return Number.parseInt(bracket[1], 10);
const loose = /rating[:\s]*([1-5])|([1-5])\s*\/\s*5|([1-5])\s*star/i.exec(event.content);
if (loose) {
const raw = loose[1] ?? loose[2] ?? loose[3];
if (raw) {
const n = Number.parseInt(raw, 10);
if (n >= 1 && n <= 5) return n;
}
}
return null;
}
/** Strip the `[N/5]` prefix and the "Reviewing: <url>" footer the old site appended. */
/**
* Shortest body that counts as a written review.
*
* Anything under this is a rating with a stray character attached, not something
* someone wrote to be read. Written reviews render as cards; the rest collapse into
* one summary line. The build time feed and the browser islands both apply this, so
* it lives here rather than in either of them.
*/
export const WRITTEN_MIN_CHARS = 3;
export function cleanReviewContent(content: string): string {
return content
.replace(/^\s*\[[1-5]\/5\]\s*/, '')
.replace(/\n+\s*Reviewing:\s*https?:\/\/\S+\s*$/i, '')
.trim();
}
/**
* Mint URLs referenced by an event, in tag priority order.
*
* `u` is where both the announcement (38172) and the review (38000) put the mint URL.
* `a` is the addressable pointer `38172:<pubkey>:<d>`, which carries no URL, so it is
* only useful for resolving via the mint's own pubkey (see `mintPubkeyRef`).
*/
export function mintUrlsFromEvent(event: NostrEventLike): string[] {
return tagValues(event.tags, 'u');
}
/**
* The mint pubkey a review points at, from the `d` tag. The old publisher set
* `d` to the pubkey from the mint's own `/v1/info`, so this resolves a review to a
* mint even when the `u` tag is missing or misspelled.
*/
export function mintPubkeyRef(event: NostrEventLike): string | null {
const d = tagValue(event.tags, 'd');
if (d && /^[0-9a-f]{64,66}$/i.test(d)) return d.toLowerCase();
const a = tagValue(event.tags, 'a');
if (a) {
const parts = a.split(':');
if (parts[0] === String(KIND_MINT_ANNOUNCEMENT) && parts[1]) {
const p = parts[1];
if (/^[0-9a-f]{64,66}$/i.test(p)) return p.toLowerCase();
}
}
return null;
}
/** True when the event's `k` tag marks it as being about a Cashu mint. */
export function isCashuMintReview(event: NostrEventLike): boolean {
return tagValue(event.tags, 'k') === String(KIND_MINT_ANNOUNCEMENT);
}
/**
* Every spelling of a mint URL that may appear in a `u` tag.
*
* The old site published and queried these six variants (useReviews.ts), so a relay
* filter that only asks for the canonical form misses most real reviews. Used by both
* the indexer and the browser so the two always agree on what belongs to a mint.
*/
export function mintUrlSpellings(mintUrl: string): string[] {
const bare = mintUrl.replace(/^https?:\/\//, '').replace(/\/+$/, '');
return [...new Set([
mintUrl,
`${mintUrl}/`,
bare,
`https://${bare}`,
`https://${bare}/`,
`http://${bare}`,
])];
}
/* ---------- kind 0 metadata ---------- */
/** Longest reviewer name rendered before it is cut, per the review card spec. */
export const MAX_PROFILE_NAME = 24;
/**
* Strip characters that have no business in a rendered name.
*
* Written as a code point scan rather than a regex because the set is exactly the
* invisible ones: C0 and C1 controls, zero width joiners and the bidi overrides that
* let a name reorder the text around it.
*/
function sanitizeText(value: unknown, max: number): string | undefined {
if (typeof value !== 'string') return undefined;
let out = '';
for (const ch of value) {
const code = ch.codePointAt(0) ?? 0;
if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) continue;
if (code >= 0x200b && code <= 0x200f) continue;
if (code >= 0x202a && code <= 0x202e) continue;
if (code >= 0x2066 && code <= 0x2069) continue;
if (code === 0xfeff) continue;
out += ch;
}
const cleaned = out.replace(/\s+/g, ' ').trim();
if (!cleaned) return undefined;
return cleaned.length > max ? `${cleaned.slice(0, max - 1)}...` : cleaned;
}
/**
* A NIP-05 is `name@domain`, and only that.
*
* Some clients park other things in the field: one reviewer's kind 0 carries their
* own npub there, which the card would otherwise render in violet as though it had
* been verified against a domain. Anything not shaped like an address is dropped.
*/
function sanitizeNip05(value: unknown, max: number): string | undefined {
const text = sanitizeText(value, max);
if (!text || text.length > max) return undefined;
if (!/^[a-z0-9._+-]+@[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(text)) return undefined;
// "_@domain" is NIP-05's root form and reads better as the bare domain.
return text.startsWith('_@') ? text.slice(2) : text;
}
/**
* Only http(s) picture URLs are accepted. A `data:` or `javascript:` picture from a
* relay has no business being written into an `img src`.
*/
function sanitizePicture(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const url = value.trim();
if (!/^https?:\/\/[^\s"'<>]+$/i.test(url) || url.length > 400) return undefined;
return url;
}
/**
* Parse a kind 0 `content` string into the fields the site renders.
*
* Defensive by construction: relay content is arbitrary text written by anyone, so
* unparseable JSON, wrong types and junk fields all degrade to "no profile" rather
* than throwing. Used by the browser island and the build time home page fetch, so
* both apply the same rules.
*/
export function parseProfileContent(pubkey: string, content: string): Profile {
let meta: Record<string, unknown>;
try {
const parsed: unknown = JSON.parse(content);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return { pubkey, found: false };
}
meta = parsed as Record<string, unknown>;
} catch {
return { pubkey, found: false };
}
const profile: Profile = {
pubkey,
name: sanitizeText(meta['name'], MAX_PROFILE_NAME),
display_name:
sanitizeText(meta['display_name'], MAX_PROFILE_NAME)
?? sanitizeText(meta['displayName'], MAX_PROFILE_NAME),
picture: sanitizePicture(meta['picture']),
nip05: sanitizeNip05(meta['nip05'], 64),
found: true,
};
// A kind 0 carrying nothing we render is the same as no kind 0 at all: the card
// keeps its npub rather than showing an empty name line.
if (!profile.name && !profile.display_name && !profile.picture && !profile.nip05) {
return { pubkey, found: false };
}
return profile;
}
/** The name a review card shows, or null when there is nothing human to show. */
export function profileName(profile: Profile | null | undefined): string | null {
if (!profile?.found) return null;
return profile.display_name ?? profile.name ?? null;
}
/**
* What a reviewer with no kind 0 is called.
*
* Most review keys on the network have published one event in their life, the review
* itself, so there is no name to show and never will be. A wall of `npub1q8f…7x2v`
* says nothing a reader can use; "Anon" says the same thing in a word. The full npub
* is still one click away on the same control, and the avatar beside it is still
* derived from the pubkey, so two anonymous reviewers remain distinguishable.
*/
export const ANON_REVIEWER = 'Anon';
+148
View File
@@ -0,0 +1,148 @@
/**
* NUT names and support parsing.
*
* The old site's utils/nutInfo.ts had several names simply wrong (it labelled NUT-04
* "Token Spending" and NUT-07 "Swap"). These are the real titles from the Cashu NUT
* specs. Numbers with no entry render as a bare chip rather than a wrong label.
*/
export const NUT_NAMES: Record<string, string> = {
'0': 'Notation and models',
'1': 'Mint public key exchange',
'2': 'Keysets and fees',
'3': 'Swapping tokens',
'4': 'Minting via Lightning',
'5': 'Melting via Lightning',
'6': 'Mint information',
'7': 'Token state check',
'8': 'Overpaid Lightning fees',
'9': 'Restore signatures',
'10': 'Spending conditions',
'11': 'Pay to public key',
'12': 'DLEQ proofs',
'13': 'Deterministic secrets',
'14': 'Hashed timelock contracts',
'15': 'Multipath payments',
'16': 'Animated QR codes',
'17': 'WebSocket subscriptions',
'18': 'Payment requests',
'19': 'Cached responses',
'20': 'Signed mint quotes',
'21': 'Clear authentication',
'22': 'Blind authentication',
'23': 'BOLT11 payment method',
'24': 'HTTP 402 payment required',
'25': 'BOLT12 payment method',
};
/** The five the mint page pulls out as named rows, per FRONTEND.md. */
export const HIGHLIGHT_NUTS = ['4', '5', '7', '17', '20'] as const;
/** How many NUTs the site knows about, for the "X of Y known NUTs supported" line. */
export const KNOWN_NUT_COUNT = Object.keys(NUT_NAMES).length;
export function nutName(n: string): string | null {
return NUT_NAMES[n] ?? null;
}
/**
* Parse the `nuts` object from a mint's /v1/info into a sorted list of supported
* NUT numbers.
*
* Mints encode support three different ways, all seen in the wild:
* "4": { "methods": [...], "disabled": false }
* "7": { "supported": true }
* "17": { "supported": [ { "method": "bolt11", ... } ] }
* plus bare `true` from a couple of older implementations.
*/
export function parseNuts(nuts: unknown): string[] {
if (!nuts || typeof nuts !== 'object') return [];
const out: string[] = [];
for (const [key, value] of Object.entries(nuts as Record<string, unknown>)) {
const num = nutNumber(key);
if (num === null) continue;
if (isSupported(value)) out.push(String(num));
}
return out.sort((a, b) => Number(a) - Number(b));
}
/** `"4"`, `"nut4"`, `"nut-04"` and `"NUT-04"` all name NUT-04. Anything else is not a NUT. */
function nutNumber(key: string): number | null {
const num = key.replace(/^nut[-_]?/i, '');
return /^\d+$/.test(num) ? Number.parseInt(num, 10) : null;
}
/**
* The object a mint published for one NUT, or null when it published none.
*
* Null means "not published", which is not the same as "switched off": see
* readCapabilities in warnings.ts, which leans on that distinction.
*/
export function nutEntry(nuts: unknown, nut: number): Record<string, unknown> | null {
if (!nuts || typeof nuts !== 'object') return null;
for (const [key, value] of Object.entries(nuts as Record<string, unknown>)) {
if (nutNumber(key) !== nut) continue;
return value && typeof value === 'object' ? (value as Record<string, unknown>) : null;
}
return null;
}
function isSupported(value: unknown): boolean {
if (value === true) return true;
if (!value || typeof value !== 'object') return false;
const v = value as Record<string, unknown>;
if (v['disabled'] === true) return false;
const supported = v['supported'];
if (supported === false) return false;
if (supported === true) return true;
if (Array.isArray(supported)) return supported.length > 0;
// `{ methods: [...] }` with no `disabled: true` means supported.
if (Array.isArray(v['methods'])) return (v['methods'] as unknown[]).length > 0;
// An object with keys and no explicit negative: treat presence as support.
return Object.keys(v).length > 0;
}
/**
* Mint or melt limits, read from the NUT-04 (default) or NUT-05 method entry.
* Returns the widest min/max across that entry's methods, which is what the
* verdict strip shows.
*/
export interface MintLimits {
min: number | null;
max: number | null;
unit: string;
}
export function parseLimits(nuts: unknown, nut: 4 | 5 = 4): MintLimits | null {
const entry = nutEntry(nuts, nut);
if (!entry) return null;
const methods = entry['methods'];
if (!Array.isArray(methods)) return null;
let min: number | null = null;
let max: number | null = null;
let unit = 'sat';
for (const m of methods as Record<string, unknown>[]) {
if (typeof m['unit'] === 'string') unit = m['unit'];
const lo = numeric(m['min_amount']);
const hi = numeric(m['max_amount']);
if (lo !== null) min = min === null ? lo : Math.min(min, lo);
if (hi !== null) max = max === null ? hi : Math.max(max, hi);
}
return min !== null || max !== null ? { min, max, unit } : null;
}
function numeric(v: unknown): number | null {
return typeof v === 'number' && Number.isFinite(v) ? v : null;
}
+72
View File
@@ -0,0 +1,72 @@
/**
* Bayesian weighted rating, replacing the old mean-only sort (see NOTES.md).
*
* score = (v / (v + m)) * R + (m / (v + m)) * C
*
* so 4 reviews of 5.0 does not outrank 39 reviews of 4.6.
*
* IMPORTANT, and a deviation from BACKEND.md worth reading before changing anything:
* BACKEND.md defines C as "global mean rating across all reviews". On this network that
* mean is about 4.7, because nearly every Cashu review is five stars. Shrinking toward a
* prior that high cannot reorder two means that are both above it, it only compresses
* them, so with C = 4.7 the spec's own example still fails:
*
* 39 reviews @ 4.6 -> 4.611 4 reviews @ 5.0 -> 4.833 1 review @ 5.0 -> 4.750
*
* A single five star review would outrank 39 considered ones. With a neutral prior the
* formula does what the spec says it should:
*
* 39 reviews @ 4.6 -> 4.418 4 reviews @ 5.0 -> 3.889 1 review @ 5.0 -> 3.333
*
* So the default prior mean is NEUTRAL_PRIOR_MEAN, not the observed global mean. Set
* SCORE_PRIOR_MEAN=global to get the literal BACKEND.md behaviour back.
*/
/** Prior weight: pretend every mint starts with 5 reviews at the prior mean. */
export const PRIOR_WEIGHT = 5;
/** Midpoint of the 1..5 scale. An unreviewed mint is neither good nor bad. */
export const NEUTRAL_PRIOR_MEAN = 3;
/** Offline mints keep their listing but sink. */
export const OFFLINE_PENALTY = 0.5;
/** No reviews in this long counts as stale. */
export const STALE_AFTER_S = 180 * 24 * 60 * 60;
export const STALE_PENALTY = 0.9;
export interface ScoreInput {
review_count: number;
rating_avg: number | null;
status: string;
last_review_at: number | null;
}
/**
* @param priorMean C, the rating an unreviewed mint is assumed to have
* @param now unix seconds, injected so the value is testable and stable within a request
*/
export function bayesianScore(m: ScoreInput, priorMean: number, now: number): number {
const v = m.review_count;
const R = m.rating_avg ?? priorMean;
let score = (v / (v + PRIOR_WEIGHT)) * R + (PRIOR_WEIGHT / (v + PRIOR_WEIGHT)) * priorMean;
if (m.status === 'offline') score *= OFFLINE_PENALTY;
if (m.last_review_at === null || now - m.last_review_at > STALE_AFTER_S) score *= STALE_PENALTY;
return Math.round(score * 1000) / 1000;
}
/**
* Default sort: every online mint before every offline one, then by score descending.
* Offline mints must stay findable (people need to reach them to review them), they
* just never appear above a live mint.
*/
export function compareMints<T extends { status: string; score: number; review_count: number }>(
a: T,
b: T,
): number {
const aOff = a.status === 'offline' ? 1 : 0;
const bOff = b.status === 'offline' ? 1 : 0;
if (aOff !== bOff) return aOff - bOff;
if (b.score !== a.score) return b.score - a.score;
return b.review_count - a.review_count;
}
+109
View File
@@ -0,0 +1,109 @@
/** Shapes returned by the API. The web app builds against these. */
export type MintStatus = 'online' | 'degraded' | 'offline' | 'unknown';
/** One item of `GET /api/mints`. */
export interface MintListItem {
url: string;
host: string;
name: string | null;
icon: string | null;
status: MintStatus;
last_online: number | null;
review_count: number;
rating_avg: number | null;
score: number;
last_review_at: number | null;
version: string | null;
}
export interface ProbeSample {
ts: number;
ok: 0 | 1;
latency_ms: number | null;
}
export type RatingDistribution = Record<'1' | '2' | '3' | '4' | '5', number>;
/** `GET /api/mints/:host`. */
export interface MintDetail extends MintListItem {
description: string | null;
pubkey: string | null;
info: MintInfo | null;
nuts: string[];
first_seen: number;
last_probe: number | null;
updated_at: number;
rating_distribution: RatingDistribution;
/** Reviews in the last 90 days, for the verdict strip's activity cell. */
reviews_90d: number;
uptime_30d: number | null;
probes_recent: ProbeSample[];
}
/** `GET /api/stats`. */
export interface Stats {
mints_total: number;
mints_online: number;
mints_offline: number;
mints_degraded: number;
reviews_total: number;
last_review_at: number | null;
updated_at: number;
}
/** `GET /api/health`. */
export interface Health {
status: 'ok' | 'degraded';
uptime_s: number;
last_probe_at: number | null;
last_discovery_at: number | null;
mints_tracked: number;
updated_at: number;
}
/**
* A mint's `/v1/info` response (NUT-06). Every field is optional: mints in the wild
* omit most of it, and the site must render whatever is there.
*/
export interface MintInfo {
name?: string;
pubkey?: string;
version?: string;
description?: string;
description_long?: string;
contact?: MintContact[];
motd?: string;
icon_url?: string;
urls?: string[];
time?: number;
tos_url?: string;
nuts?: Record<string, unknown>;
}
export interface MintContact {
method?: string;
info?: string;
}
/** A review as rendered in the browser, parsed from a Nostr event. */
export interface Review {
id: string;
pubkey: string;
created_at: number;
mint_url: string;
/** null when the event carries no parseable rating. Never defaulted to 5. */
rating: number | null;
content: string;
}
/** Resolved kind-0 metadata for a reviewer. */
export interface Profile {
pubkey: string;
name?: string;
display_name?: string;
picture?: string;
nip05?: string;
/** false when the kind-0 lookup returned nothing, used for the provenance note. */
found: boolean;
}
+419
View File
@@ -0,0 +1,419 @@
/**
* The states where a mint cannot do its basic job, derived in one place.
*
* A mint page, a mint card and the live-status island must never disagree about
* whether a mint can take deposits or pay them out, so all three call this.
*
* Everything here is derived from what the API already returns: the cached
* `/v1/info` (NUT-04 and NUT-05), plus `status` and `last_online`. The cache is the
* point: an offline mint still has its last known configuration, and the copy says
* "had" rather than "has" so nobody reads a stale flag as a live one.
*/
import type { MintInfo, MintStatus } from './types.js';
import { nutEntry } from './nuts.js';
/** Methods that move sats over Lightning. Anything else cannot stand in for them. */
const LIGHTNING_METHODS = new Set(['bolt11', 'bolt12']);
export interface MintCapabilities {
/** NUT-04 off: no new ecash can be minted, so deposits are impossible. */
mintDisabled: boolean;
/** NUT-05 off: ecash cannot be melted, so withdrawals are impossible. */
meltDisabled: boolean;
/** false when the mint never published the entry at all, which is not the same as off. */
mintPublished: boolean;
meltPublished: boolean;
}
/**
* Read the NUT-04 / NUT-05 switches out of a mint's `nuts` object.
*
* Both cdk-mintd and Nutshell publish `{ "4": { methods: [...], disabled: bool } }`,
* and every mint indexed so far uses that exact shape with plain numeric keys. Two
* things still vary:
*
* - the flag can be absent, which means enabled (Nutshell omits it on some builds);
* - the methods list can be empty while `disabled` is true (LekMint ships both).
*
* An entry that lists methods but none over Lightning is treated as off, because the
* question this answers is "can sats move in and out over Lightning", and an
* onchain-only or venmo-only method list answers no.
*
* A missing entry is NOT reported as disabled. Not publishing a NUT is a different
* fact from switching it off, and warning about the first would cry wolf.
*/
export function readCapabilities(nuts: unknown): MintCapabilities {
const mint = nutEntry(nuts, 4);
const melt = nutEntry(nuts, 5);
return {
mintDisabled: isDisabled(mint),
meltDisabled: isDisabled(melt),
mintPublished: mint !== null,
meltPublished: melt !== null,
};
}
function isDisabled(entry: Record<string, unknown> | null): boolean {
if (!entry) return false;
if (entry['disabled'] === true) return true;
const methods = entry['methods'];
if (Array.isArray(methods)) return !methods.some(isLightningMethod);
return false;
}
function isLightningMethod(method: unknown): boolean {
if (!method || typeof method !== 'object') return false;
const name = (method as Record<string, unknown>)['method'];
return typeof name === 'string' && LIGHTNING_METHODS.has(name.toLowerCase());
}
export type MintWarningSeverity = 'critical' | 'warning';
export type MintWarningKind =
| 'gone' // offline 30 days or more, or never reached at all
| 'melt-disabled' // sats can get in but not out
| 'frozen' // neither in nor out
| 'offline-long' // offline 7 to 30 days
| 'melt-only' // minting off, melting still works
| 'offline'; // offline under 7 days
export interface MintWarning {
kind: MintWarningKind;
severity: MintWarningSeverity;
/** The one short bold lead phrase, punctuation included. */
lead: string;
/** Plain sentences after the lead. Already formatted, never contains markup. */
body: string;
/** Short tail for the page's meta description, so search results carry the state. */
meta: string;
}
/**
* Where the words come from.
*
* The decisions in this file (which state a mint is in, which banner outranks which,
* what gets folded into the winner's last sentence) are language-free, and they have to
* stay that way: three locales agreeing about the facts and disagreeing about the
* severity order would be worse than not translating at all. So the logic stays here
* and only the sentences are looked up, by key, through this.
*
* Keys are the catalog's own, minus the `mint.warnings.` prefix: `gone.lead`,
* `meltOnly.body.online`, `dayCount` with an `n`. Left unset, `WARNING_COPY_EN` answers
* and the output is exactly what it has always been, which is what the API's
* fixture tests assert against.
*/
export type WarningStrings = (key: string, vars?: Record<string, string | number>) => string;
export interface MintWarningInput {
status: MintStatus | string;
last_online: number | null;
/** Cached `/v1/info`, as `GET /api/mints/:host` returns it. */
info?: MintInfo | null;
/**
* Precomputed switches, for a caller that already read them (a card built from the
* list payload, which carries no info). Wins over `info` when both are given.
*/
capabilities?: Pick<MintCapabilities, 'mintDisabled' | 'meltDisabled'> | null;
/** Only used to date "never answered a single check". */
first_seen?: number;
}
export interface MintWarningOptions {
/** Unix seconds. Injected so the day counts are testable. */
now?: number;
formatDate?: (unix: number) => string;
formatMonth?: (unix: number) => string;
/** The locale's sentences. Defaults to English, see `WarningStrings`. */
strings?: WarningStrings;
}
/**
* The English warning copy, and the only place it is written down.
*
* `web/src/i18n/en.json` carries the same keys under `mint.warnings.`, and
* `web/scripts/check-i18n.mjs` diffs the two on every build: this file and that
* catalog disagreeing about what "withdrawals disabled" says is exactly the kind of
* drift nobody notices until a reader is looking at two different warnings on two
* pages about the same mint.
*/
export const WARNING_COPY_EN: Record<string, string> = {
'dayCount.one': '{n} day',
'dayCount.other': '{n} days',
'gone.lead': 'Likely gone.',
'gone.body.since':
'This mint has been unreachable since {date} ({days}). Treat funds held there as at risk, and do not send new funds. Details below show its last known state.',
'gone.body.neverDated':
'This mint has not answered a single check since we started watching it in {month}. Treat funds held there as at risk, and do not send new funds. Anything below came from the Nostr announcement, not from the mint.',
'gone.body.never':
'This mint has not answered a single check since we started watching it. Treat funds held there as at risk, and do not send new funds. Anything below came from the Nostr announcement, not from the mint.',
'gone.meta.since': 'Offline since {date}',
'gone.meta.never': 'Never reachable since it was discovered',
'meltDisabled.lead': 'Withdrawals disabled.',
'meltDisabled.body.online':
'This mint has Lightning melting (NUT-05) turned off: you can put sats in, but you cannot withdraw them via Lightning. Do not deposit until melting is back.',
'meltDisabled.body.offline':
'This mint had Lightning melting (NUT-05) turned off: you can put sats in, but you cannot withdraw them via Lightning. Do not deposit until melting is back.',
'meltDisabled.meta.online': 'Withdrawals currently disabled',
'meltDisabled.meta.offline': 'Withdrawals were disabled when last seen',
'frozen.lead': 'Mint frozen.',
'frozen.body.online': 'Both minting and melting are disabled. No sats can move in or out via Lightning.',
'frozen.body.offline': 'Both minting and melting were disabled. No sats can move in or out via Lightning.',
'frozen.meta.online': 'Currently frozen, nothing moves in or out',
'frozen.meta.offline': 'Frozen when last seen',
'offlineLong.lead': 'Offline for {days}, since {date}.',
'offlineLong.body': 'The mint may be gone. Everything below is its last known state, and reviews still work.',
'offlineLong.meta': 'Offline since {date}',
'meltOnly.lead': 'Melt only.',
'meltOnly.body.online':
'New minting (NUT-04) is disabled: you cannot deposit, but existing ecash can still be withdrawn. This often means a mint is winding down, so if you hold its ecash, consider melting out.',
'meltOnly.body.offline':
'New minting (NUT-04) was disabled: you cannot deposit, but existing ecash can still be withdrawn. This often means a mint is winding down, so if you hold its ecash, consider melting out.',
'meltOnly.meta.online': 'Currently melt only',
'meltOnly.meta.offline': 'Melt only when last seen',
'offline.lead': 'Offline since {date}.',
'offline.body': 'Details were last confirmed then. You can still write a review.',
'offline.meta': 'Offline since {date}',
'also.meltDisabled': 'Before going offline it had also disabled withdrawals.',
'also.mintDisabled': 'Before going offline it had also disabled new minting.',
'also.offline': 'It has also been offline since {date} ({days}).',
};
/**
* The default resolver: English, with the one plural this file needs.
*
* English, Spanish and Dutch all split on `n === 1`, so the rule here is that split and
* nothing more. A caller in a language that needs more categories passes its own
* `strings`, which is backed by `Intl.PluralRules` and gets it right for every language
* at once. Nothing in this file has to know that.
*/
const englishStrings: WarningStrings = (key, vars) => {
const n = typeof vars?.['n'] === 'number' ? (vars['n'] as number) : undefined;
const resolved =
n !== undefined
? (WARNING_COPY_EN[`${key}.${n === 1 ? 'one' : 'other'}`] ?? WARNING_COPY_EN[key])
: WARNING_COPY_EN[key];
if (resolved === undefined) return key;
if (!vars) return resolved;
return resolved.replace(/\{(\w+)\}/g, (whole, name: string) => {
const value = vars[name];
return value === undefined ? whole : String(value);
});
};
/** Severity order, highest first. Index 0 of the returned list is the one to show. */
const ORDER: MintWarningKind[] = [
'gone', 'melt-disabled', 'frozen', 'offline-long', 'melt-only', 'offline',
];
const defaultDate = (unix: number): string =>
new Date(unix * 1000).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' });
const defaultMonth = (unix: number): string =>
new Date(unix * 1000).toLocaleDateString('en-GB', { month: 'short', year: 'numeric' });
/**
* Every warning that applies, most severe first.
*
* Pages show only `[0]`: a stack of banners is a wall nobody reads, and the secondary
* conditions are folded into the winner's last sentence instead. The rest of the list
* is still returned because a card needs to know a mint is frozen even when its
* offline banner outranks that fact.
*/
export function getMintWarnings(
mint: MintWarningInput,
options: MintWarningOptions = {},
): MintWarning[] {
const now = options.now ?? Math.floor(Date.now() / 1000);
const date = options.formatDate ?? defaultDate;
const month = options.formatMonth ?? defaultMonth;
const s = options.strings ?? englishStrings;
const caps = mint.capabilities ?? readCapabilities(mint.info?.nuts);
const { mintDisabled, meltDisabled } = caps;
const offline = mint.status === 'offline';
const since = mint.last_online;
const days = offline && since !== null ? Math.max(0, Math.floor((now - since) / 86400)) : null;
// Offline with no last_online means it has never once answered, which is at least
// as bad as a month of silence, so it lands in the top tier rather than the bottom.
const tier = !offline ? 0 : days === null ? 3 : days >= 30 ? 3 : days >= 7 ? 2 : 1;
/*
* Cached flags describe the last configuration seen, not the current one, and the
* copy has to say so. English did that by swapping one verb ("has" for "had"); most
* languages cannot, so the tense is a whole sentence and each state carries a
* `.online` and an `.offline` variant. The key is picked here, the grammar lives in
* the catalogs where a translator can see all of it at once.
*/
const tense = offline ? 'offline' : 'online';
const dayCount = days === null ? s('dayCount', { n: 0 }) : s('dayCount', { n: days });
const out: MintWarning[] = [];
// Pushed in ORDER, so the first one added is the one that wins.
if (tier === 3) {
out.push({
kind: 'gone',
severity: 'critical',
lead: s('gone.lead'),
body:
since !== null
? s('gone.body.since', { date: date(since), days: dayCount })
: mint.first_seen
? s('gone.body.neverDated', { month: month(mint.first_seen) })
: s('gone.body.never'),
meta: since !== null ? s('gone.meta.since', { date: date(since) }) : s('gone.meta.never'),
});
}
if (meltDisabled && !mintDisabled) {
out.push({
kind: 'melt-disabled',
severity: 'critical',
lead: s('meltDisabled.lead'),
body: s(`meltDisabled.body.${tense}`),
meta: s(`meltDisabled.meta.${tense}`),
});
}
if (meltDisabled && mintDisabled) {
out.push({
kind: 'frozen',
severity: 'critical',
lead: s('frozen.lead'),
body: s(`frozen.body.${tense}`),
meta: s(`frozen.meta.${tense}`),
});
}
if (tier === 2 && since !== null) {
out.push({
kind: 'offline-long',
severity: 'critical',
lead: s('offlineLong.lead', { days: dayCount, date: date(since) }),
body: s('offlineLong.body'),
meta: s('offlineLong.meta', { date: date(since) }),
});
}
if (mintDisabled && !meltDisabled) {
out.push({
kind: 'melt-only',
severity: 'warning',
lead: s('meltOnly.lead'),
body: s(`meltOnly.body.${tense}`),
meta: s(`meltOnly.meta.${tense}`),
});
}
if (tier === 1 && since !== null) {
out.push({
kind: 'offline',
severity: 'warning',
lead: s('offline.lead', { date: date(since) }),
body: s('offline.body'),
meta: s('offline.meta', { date: date(since) }),
});
}
const primary = out[0];
if (primary) {
const extra = collapsed(primary.kind, { mintDisabled, meltDisabled, offline, since, dayCount, date, s });
if (extra) primary.body += ` ${extra}`;
}
return out;
}
/**
* The one sentence a losing condition earns inside the winner's text. Banners never
* stack, but a mint that is both gone and had stopped paying out is a worse story
* than either fact alone, and the page has to tell it.
*/
const CAPABILITY_KINDS = new Set<MintWarningKind>(['melt-disabled', 'frozen', 'melt-only']);
function collapsed(
kind: MintWarningKind,
ctx: {
mintDisabled: boolean;
meltDisabled: boolean;
offline: boolean;
since: number | null;
dayCount: string;
date: (unix: number) => string;
s: WarningStrings;
},
): string | null {
if (kind === 'gone' || kind === 'offline-long') {
if (ctx.meltDisabled) return ctx.s('also.meltDisabled');
if (ctx.mintDisabled) return ctx.s('also.mintDisabled');
return null;
}
// A capability banner outranked an offline one: say the mint is also unreachable,
// otherwise the page reads as if it were up and merely misconfigured. Only the
// capability banners need this; an offline banner has already said it.
if (CAPABILITY_KINDS.has(kind) && ctx.offline && ctx.since !== null) {
return ctx.s('also.offline', { date: ctx.date(ctx.since), days: ctx.dayCount });
}
return null;
}
/** The winner, or null for a healthy mint. */
export function primaryWarning(
mint: MintWarningInput,
options: MintWarningOptions = {},
): MintWarning | null {
return getMintWarnings(mint, options)[0] ?? null;
}
/**
* The chip a mint card carries next to its status.
*
* Cards say what is broken in two words and stop there; the banner on the mint page
* does the explaining. Offline already has its own card styling, so only the
* capability states produce a chip.
*/
export interface MintChip {
label: string;
severity: MintWarningSeverity;
}
/**
* The chip's three labels, in English. Keyed to match `mint.chip.*` in the catalogs.
*
* Separate from `WARNING_COPY_EN` because these are not warning sentences: they are two
* words on a card, and a translator sizing them has a different job.
*/
export const CHIP_COPY_EN: Record<string, string> = {
frozen: 'Frozen',
noWithdrawals: 'No withdrawals',
meltOnly: 'Melt only',
};
export function mintChip(warnings: MintWarning[], strings?: WarningStrings): MintChip | null {
const s = strings ?? ((key: string) => CHIP_COPY_EN[key] ?? key);
const kinds = new Set(warnings.map((w) => w.kind));
if (kinds.has('frozen')) return { label: s('frozen'), severity: 'critical' };
// "Melt only" is the other direction, so it cannot double as the label here.
if (kinds.has('melt-disabled')) return { label: s('noWithdrawals'), severity: 'critical' };
if (kinds.has('melt-only')) return { label: s('meltOnly'), severity: 'warning' };
return null;
}
/** Sort key, exported so a caller can assert the order rather than assume it. */
export function warningRank(kind: MintWarningKind): number {
return ORDER.indexOf(kind);
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": false,
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*.ts"]
}
+79
View File
@@ -0,0 +1,79 @@
// Must come first: it populates process.env from the workspace .env, and everything
// below reads process.env.
import './scripts/load-env.mjs';
import { defineConfig } from 'astro/config';
import { checkCatalogs } from './scripts/i18n-build-check.mjs';
import { DEFAULT_LOCALE, LOCALE_CODES } from './src/i18n/locales.mjs';
const site = process.env.SITE_URL ?? 'https://cashumints.space';
/*
* Catalog check, before anything renders.
*
* A missing key is a warning listing every key by locale, because English is a working
* fallback and half a language beats none. A key used in the source that no catalog
* defines, or an island reaching for a string the page never inlines, is an error and
* stops the build: both of those put a humanised key on a real page.
*/
await checkCatalogs();
// Where the build (and the dev server proxy) reaches the API. This is a private,
// build-machine address by default and must never end up in the browser's markup.
const apiUrl = process.env.API_URL ?? 'http://127.0.0.1:8787';
// Browser-facing API origin. Empty means same origin: islands fetch /api/... and icons
// resolve to /icons/..., which the dev proxy below and nginx in production forward to
// the API. Set PUBLIC_API_URL only when the API is served from its own origin, e.g.
// https://api.cashumints.space.
const publicApiUrl = process.env.PUBLIC_API_URL ?? '';
// Dev server and preview port. Set WEB_PORT in .env; a --port flag still overrides it.
const port = Number.parseInt(process.env.WEB_PORT ?? '', 10) || 4321;
// Same-origin mode only works if something forwards those two prefixes; in dev that is
// this proxy. With PUBLIC_API_URL set, requests skip the dev server entirely.
const proxy = publicApiUrl
? undefined
: {
'/api': { target: apiUrl, changeOrigin: true },
'/icons': { target: apiUrl, changeOrigin: true },
};
export default defineConfig({
site,
output: 'static',
trailingSlash: 'ignore',
/*
* Path-prefix locales, English at the root: /mints, /es/mints, /nl/mints.
*
* `prefixDefaultLocale: false` is what keeps every existing English URL exactly where
* it was, which matters more here than tidiness: those URLs are indexed, linked and
* pasted into wallets. Astro uses this to resolve `Astro.currentLocale` and to route
* in dev; the pages themselves are emitted per locale by `getStaticPaths` in
* `src/pages/[...locale]/`, so the whole set is prerendered rather than negotiated.
*
* `redirectToDefaultLocale: false`: a visitor asking for /es/thing that does not
* exist gets a 404, not a silent bounce to English. Redirecting by language is
* exactly what this site does not do.
*/
i18n: {
defaultLocale: DEFAULT_LOCALE,
locales: LOCALE_CODES,
routing: {
prefixDefaultLocale: false,
redirectToDefaultLocale: false,
},
},
build: { inlineStylesheets: 'auto' },
devToolbar: { enabled: false },
server: { port },
vite: {
define: {
'import.meta.env.PUBLIC_API_URL': JSON.stringify(publicApiUrl),
},
server: { proxy },
},
});
+5
View File
@@ -0,0 +1,5 @@
{
"breakpoints": [360, 768, 1280],
"out": "./src/bones",
"wait": 2000
}
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@cashumints/web",
"version": "2.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"typecheck": "astro check",
"bones": "node --env-file-if-exists=../.env scripts/bones.mjs",
"check:links": "node scripts/check-links.mjs dist",
"check:i18n": "node scripts/check-i18n.mjs",
"check:hreflang": "node scripts/check-hreflang.mjs dist"
},
"dependencies": {
"@cashumints/shared": "workspace:*",
"astro": "^5.1.1",
"nostr-tools": "^2.10.4",
"qrcode-generator": "^2.0.4"
},
"devDependencies": {
"@astrojs/check": "^0.9.4",
"boneyard-js": "^1.9.0",
"typescript": "^5.6.3"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 888 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 910 B

+1
View File
@@ -0,0 +1 @@
<svg width="11" height="13" viewBox="0 0 11 13" shape-rendering="crispEdges" xmlns="http://www.w3.org/2000/svg"><rect x="2" y="0" width="7" height="1" fill="#C9BFA4"/><rect x="1" y="1" width="9" height="2" fill="#C9BFA4"/><rect x="1" y="3" width="2" height="7" fill="#A89B7C"/><rect x="3" y="3" width="6" height="7" fill="#C9BFA4"/><rect x="3" y="4" width="2" height="1" fill="#5C5340"/><rect x="7" y="4" width="2" height="1" fill="#5C5340"/><rect x="5" y="5" width="1" height="3" fill="#A89B7C"/><rect x="2" y="10" width="8" height="2" fill="#A89B7C"/></svg>

After

Width:  |  Height:  |  Size: 559 B

+303
View File
@@ -0,0 +1,303 @@
The three typefaces served from this directory, and their licences.
All three are licensed under the SIL Open Font License, Version 1.1. The files here are
the unmodified `latin` and `latin-ext` woff2 subsets as served by Google Fonts; they are
self-hosted so that visiting this site does not make a request to a third party. The
full notice for each family follows.
Space Grotesk https://github.com/floriankarsten/space-grotesk
Inter https://github.com/rsms/inter
JetBrains Mono https://github.com/JetBrains/JetBrainsMono
================================================================================
Space Grotesk
================================================================================
Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
================================================================================
Inter
================================================================================
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
================================================================================
JetBrains Mono
================================================================================
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

+14
View File
@@ -0,0 +1,14 @@
{
"name": "Cashumints.space",
"short_name": "Cashumints",
"description": "Find a Cashu mint you can trust. Every mint on the Nostr network, with community reviews you can verify yourself.",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
],
"theme_color": "#0E0D0B",
"background_color": "#0E0D0B",
"display": "standalone",
"start_url": "/"
}
+88
View File
@@ -0,0 +1,88 @@
/**
* Regenerate the Boneyard bone files.
*
* Boneyard measures a running page, so this needs `pnpm dev` up. It captures exactly
* two routes and does not crawl:
*
* - a real mint page, for the reviews panel's bones
* - a /mint/ URL with no prerendered page, for the prerender miss skeleton
*
* The mint host is looked up from the API rather than hardcoded, so this keeps
* working as mints come and go. Both islands render fixture content when the CLI
* sets `window.__BONEYARD_BUILD`, so neither capture depends on a relay answering.
*
* Re-run after any layout change to a review card or to the prerender miss summary.
*/
import { spawn } from 'node:child_process';
import { rmSync } from 'node:fs';
const WEB_PORT = process.env['WEB_PORT'] ?? '4321';
const WEB_URL = process.env['BONES_URL'] ?? `http://localhost:${WEB_PORT}`;
const API_URL = process.env['API_URL'] ?? 'http://127.0.0.1:8787';
/** A host that is never prerendered, so Astro serves the 404 fallback for it. */
const MISS_HOST = 'not-prerendered.boneyard.invalid';
function fail(message) {
console.error(`\n bones: ${message}\n`);
process.exit(1);
}
async function reachable(url) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(4000) });
return res.ok;
} catch {
return false;
}
}
if (!(await reachable(WEB_URL))) {
fail(`no dev server at ${WEB_URL}. Start one with "pnpm dev", then run this again.`);
}
/**
* The dev server proxies /api to the API in same origin mode, so try it first and
* fall back to the API's own address for setups that set PUBLIC_API_URL.
*/
async function firstMintHost() {
for (const base of [WEB_URL, API_URL]) {
try {
const res = await fetch(`${base}/api/mints`, { signal: AbortSignal.timeout(8000) });
if (!res.ok) continue;
const mints = await res.json();
if (Array.isArray(mints) && mints[0]?.host) return mints[0].host;
} catch {
// Try the next base.
}
}
return null;
}
const host = await firstMintHost();
if (!host) {
fail(`no mints from the API (tried ${WEB_URL} and ${API_URL}). Start it with "pnpm dev:api".`);
}
const targets = [`${WEB_URL}/mint/${host}`, `${WEB_URL}/mint/${MISS_HOST}`];
console.log(`\n bones: measuring\n ${targets.join('\n ')}\n`);
// Passing explicit non-root paths puts the CLI in single page mode: it visits only
// these URLs, with no link following and no filesystem route scan.
//
// --force because Boneyard decides a skeleton is unchanged by hashing the captured
// element's innerHTML. Bone geometry depends just as much on CSS, so a styling change
// alone would silently reuse stale bones. There are only two skeletons here, so
// always recapturing costs a few seconds and removes the trap.
const child = spawn('boneyard-js', ['build', '--force', ...targets], { stdio: 'inherit', shell: false });
child.on('error', (error) => fail(`could not run boneyard-js (${error.message}).`));
child.on('exit', (code) => {
// Boneyard also writes a registry that calls registerBones() through a framework
// adapter, defaulting to boneyard-js/react. We have no React and no <Skeleton>
// components: the islands import the .bones.json files directly. Leaving an
// unresolvable import in src/ would only break typechecking.
rmSync(new URL('../src/bones/registry.ts', import.meta.url), { force: true });
rmSync(new URL('../src/bones/registry.js', import.meta.url), { force: true });
process.exit(code ?? 1);
});
+300
View File
@@ -0,0 +1,300 @@
/**
* The i18n SEO check, over the built site.
*
* `check-links.mjs` answers "does every link go somewhere". This answers the question
* that matters once a site is in three languages: does every page correctly declare
* which language it is, which page it is, and where its siblings are. Those three
* declarations are what stop three translations of one page being indexed as three
* duplicates, and every one of them is easy to get subtly wrong in a way nothing else
* catches.
*
* Seven checks per page:
*
* lang <html lang> matches the locale its URL is under
* canonical present, absolute, and pointing at itself (never at another locale)
* alternates one per locale, plus exactly one x-default
* absolute every alternate href is absolute
* targets every alternate href is a page the build actually emitted
* reciprocal every alternate lists this page back, under this page's own hreflang
* x-default points at the default locale's version of this same page
*
* Plus, over the sitemap: every emitted page appears exactly once, with its own full
* xhtml:link alternate set.
*
* Usage: node scripts/check-hreflang.mjs [distDir]
* Exits non-zero on any problem, so it can gate a deploy.
*/
import { readdir, readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const dist = path.resolve(process.argv[2] ?? path.join(root, 'dist'));
if (!existsSync(dist)) {
console.error(`No such directory: ${dist}. Run \`pnpm build\` first.`);
process.exit(2);
}
const { DEFAULT_LOCALE, LOCALE_CODES } = await import(path.join(root, 'src/i18n/locales.mjs'));
const PREFIXED = LOCALE_CODES.filter((code) => code !== DEFAULT_LOCALE);
/* ---------- what the build emitted ---------- */
async function walk(dir, base = dir) {
const out = [];
for (const entry of await readdir(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...(await walk(full, base)));
else out.push(path.relative(base, full).split(path.sep).join('/'));
}
return out;
}
const files = await walk(dist);
const pages = files.filter((f) => f.endsWith('.html'));
/** dist-relative file to the route a visitor asks for. */
const routeOf = (file) =>
file === 'index.html' ? '/' : '/' + file.replace(/\/index\.html$/, '').replace(/\.html$/, '');
/** The locale a route is under, and the route with that prefix removed. */
function split(route) {
const first = route.split('/').filter(Boolean)[0];
if (first && PREFIXED.includes(first)) {
const rest = route.slice(first.length + 1);
return { locale: first, path: rest === '' ? '/' : rest };
}
return { locale: DEFAULT_LOCALE, path: route };
}
const problems = [];
const fail = (page, kind, detail) => problems.push({ page, kind, detail });
/* ---------- read every page ---------- */
const LANG = /<html[^>]*\slang="([^"]*)"/i;
const CANONICAL = /<link[^>]*rel="canonical"[^>]*href="([^"]*)"/i;
const ROBOTS = /<meta[^>]*name="robots"[^>]*content="([^"]*)"/i;
const ALTERNATE = /<link[^>]*rel="alternate"[^>]*hreflang="([^"]*)"[^>]*href="([^"]*)"/gi;
const seen = new Map(); // route -> { locale, path, lang, canonical, alternates: Map }
const routes = new Set();
for (const file of pages) {
const route = routeOf(file);
routes.add(route);
const html = await readFile(path.join(dist, file), 'utf8');
const alternates = new Map();
for (const match of html.matchAll(ALTERNATE)) alternates.set(match[1], match[2]);
seen.set(route, {
...split(route),
route,
lang: LANG.exec(html)?.[1] ?? null,
canonical: CANONICAL.exec(html)?.[1] ?? null,
robots: ROBOTS.exec(html)?.[1] ?? null,
alternates,
});
}
/*
* The 404 page is the one route with no place in the alternate graph.
*
* It is not a page anyone links to or a crawler should index; it is what a web server
* hands back for a URL that does not exist. It still gets a `lang` and it still gets
* one per locale, which is what the checks below verify, but it is deliberately absent
* from the sitemap and it is not required to be reciprocal with anything.
*/
const isOffGraph = (page) => page.path === '/404';
/**
* A page that exists but asks not to be indexed.
*
* Mint pages the site has never reached and nobody has reviewed carry this (see
* `src/lib/seo.ts`). Unlike the 404 they are real pages: they keep their canonical and
* their full alternate set, and only the sitemap leaves them out.
*/
const isNoindex = (page) => Boolean(page.robots?.includes('noindex'));
/* ---------- the seven checks ---------- */
const origins = new Set();
for (const page of seen.values()) {
if (page.lang !== page.locale) {
fail(page.route, 'lang', `<html lang="${page.lang}"> under /${page.locale}/`);
}
if (isOffGraph(page)) {
// No canonical, no alternates, and a robots meta saying so. Checked rather than
// skipped: "the 404 has no alternate set" is a claim worth verifying, because the
// easy mistake is for it to quietly acquire one.
if (page.canonical) fail(page.route, 'noindex', `404 has a canonical: ${page.canonical}`);
if (page.alternates.size > 0) {
fail(page.route, 'noindex', `404 declares ${page.alternates.size} alternate(s)`);
}
if (!page.robots?.includes('noindex')) fail(page.route, 'noindex', '404 is missing noindex');
continue;
}
if (!page.canonical) {
fail(page.route, 'canonical', 'no <link rel="canonical">');
continue;
}
if (!/^https?:\/\//.test(page.canonical)) {
fail(page.route, 'canonical', `relative: ${page.canonical}`);
continue;
}
const canonicalUrl = new URL(page.canonical);
origins.add(canonicalUrl.origin);
// Self-canonical. A locale canonicalising to another locale tells a crawler this
// page is a duplicate that should not be indexed, which is the exact opposite of
// what the alternate set beside it is claiming.
if (canonicalUrl.pathname.replace(/\/$/, '') !== page.route.replace(/\/$/, '')) {
fail(page.route, 'canonical', `points at ${canonicalUrl.pathname}, not at itself`);
}
// One per locale, plus x-default, and nothing else.
const expected = [...LOCALE_CODES, 'x-default'];
for (const hreflang of expected) {
if (!page.alternates.has(hreflang)) {
fail(page.route, 'alternates', `missing hreflang="${hreflang}"`);
}
}
for (const hreflang of page.alternates.keys()) {
if (!expected.includes(hreflang)) {
fail(page.route, 'alternates', `unexpected hreflang="${hreflang}"`);
}
}
for (const [hreflang, href] of page.alternates) {
if (!/^https?:\/\//.test(href)) {
fail(page.route, 'absolute', `hreflang="${hreflang}" is relative: ${href}`);
continue;
}
const target = new URL(href).pathname.replace(/\/$/, '') || '/';
if (!routes.has(target)) {
fail(page.route, 'targets', `hreflang="${hreflang}" points at ${target}, which was not emitted`);
continue;
}
if (hreflang === 'x-default') {
const expectedDefault = split(target);
if (expectedDefault.locale !== DEFAULT_LOCALE || expectedDefault.path !== page.path) {
fail(page.route, 'x-default', `points at ${target}, not at the ${DEFAULT_LOCALE} version of ${page.path}`);
}
continue;
}
// Reciprocity: the page this one points at has to point back, at this page, under
// this page's own hreflang. A set that is not reciprocal is discarded whole, so a
// one-sided link is worth less than no link.
const other = seen.get(target);
const back = other?.alternates.get(page.locale);
if (!back) {
fail(page.route, 'reciprocal', `${target} does not list hreflang="${page.locale}"`);
continue;
}
const backPath = new URL(back).pathname.replace(/\/$/, '') || '/';
if (backPath !== (page.route.replace(/\/$/, '') || '/')) {
fail(page.route, 'reciprocal', `${target} lists hreflang="${page.locale}" as ${backPath}`);
}
}
}
if (origins.size > 1) {
fail('(site)', 'origin', `canonical URLs use more than one origin: ${[...origins].join(', ')}`);
}
/* ---------- the sitemap ---------- */
const sitemapPath = path.join(dist, 'sitemap.xml');
let sitemapCount = 0;
if (!existsSync(sitemapPath)) {
fail('(sitemap)', 'sitemap', 'sitemap.xml was not emitted');
} else {
const xml = await readFile(sitemapPath, 'utf8');
const blocks = [...xml.matchAll(/<url>([\s\S]*?)<\/url>/g)].map((m) => m[1]);
sitemapCount = blocks.length;
const listed = new Map();
for (const block of blocks) {
const loc = /<loc>([^<]*)<\/loc>/.exec(block)?.[1];
if (!loc) {
fail('(sitemap)', 'sitemap', 'a <url> block has no <loc>');
continue;
}
const route = new URL(loc).pathname.replace(/\/$/, '') || '/';
if (listed.has(route)) fail(route, 'sitemap', 'listed more than once');
const alternates = new Set(
[...block.matchAll(/<xhtml:link[^>]*hreflang="([^"]*)"/g)].map((m) => m[1]),
);
listed.set(route, alternates);
for (const hreflang of [...LOCALE_CODES, 'x-default']) {
if (!alternates.has(hreflang)) {
fail(route, 'sitemap', `entry is missing xhtml:link hreflang="${hreflang}"`);
}
}
}
/*
* The invariant, both ways round: every indexable page is listed, and nothing that
* asks not to be indexed is.
*
* Checking it here rather than trusting the two files to agree is the point. The
* page's robots meta and the sitemap are produced by different code from one shared
* predicate, and this reads the built output of both, so the day someone changes one
* without the other the build says so.
*/
for (const page of seen.values()) {
if (isNoindex(page)) {
if (listed.has(page.route)) {
fail(page.route, 'sitemap', 'says noindex but is listed in the sitemap');
}
continue;
}
if (!listed.has(page.route)) fail(page.route, 'sitemap', 'emitted but not in the sitemap');
}
for (const route of listed.keys()) {
if (!routes.has(route)) fail(route, 'sitemap', 'listed but never emitted');
}
}
/* ---------- report ---------- */
const localeCounts = LOCALE_CODES.map(
(code) => `${code}: ${[...seen.values()].filter((p) => p.locale === code).length}`,
).join(', ');
console.log(
`check-hreflang: ${pages.length} pages (${localeCounts}), ${sitemapCount} sitemap entries.`,
);
if (problems.length === 0) {
console.log('Every page declares its language, canonicalises to itself, and has a complete reciprocal alternate set.');
process.exit(0);
}
const grouped = new Map();
for (const problem of problems) {
if (!grouped.has(problem.kind)) grouped.set(problem.kind, []);
grouped.get(problem.kind).push(problem);
}
console.log(`\n${problems.length} problem(s):`);
for (const [kind, list] of grouped) {
console.log(`\n [${kind}] ${list.length}`);
for (const problem of list.slice(0, 15)) console.log(` ${problem.page}: ${problem.detail}`);
if (list.length > 15) console.log(` … and ${list.length - 15} more`);
}
process.exit(1);
+341
View File
@@ -0,0 +1,341 @@
/**
* Catalog checker. Runs before every build, and on its own via `pnpm check:i18n`.
*
* Four questions, all of which have bitten a multilingual site before:
*
* missing a key English has and this locale does not (falls back, but silently)
* unknown a key a locale has and English does not (a typo, or a stale key)
* undefined a `t('...')` in the source that no catalog defines (would render a
* humanised key on a real page)
* client an island reaching for a key outside CLIENT_NAMESPACES, which would
* not be in the page's inline payload and would render as a humanised key
*
* Missing keys are a warning: English is a working fallback and a half-translated
* language is better than no language. Everything else is an error, because every one
* of them puts a wrong string in front of a visitor.
*
* Usage: node scripts/check-i18n.mjs [--strict]
* --strict also fails on missing keys, for a release build.
*/
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const i18nDir = path.join(root, 'src/i18n');
const srcDir = path.join(root, 'src');
const strict = process.argv.includes('--strict');
/* ---------- the locale table, read from config.ts ---------- */
/*
* Parsed out of the source rather than imported, because config.ts is TypeScript and
* this script runs on plain node before anything is compiled. The two shapes it reads
* are the two the file is allowed to have; a change to either fails loudly here rather
* than quietly checking nothing.
*/
const configSource = await readFile(path.join(i18nDir, 'config.ts'), 'utf8');
const localeCodes = [...configSource.matchAll(/\{\s*code:\s*'([a-z-]+)'/g)].map((m) => m[1]);
const defaultLocale = /DEFAULT_LOCALE\s*=\s*'([a-z-]+)'/.exec(configSource)?.[1];
const clientBlock = /CLIENT_NAMESPACES\s*=\s*\[([^\]]*)\]/s.exec(configSource)?.[1] ?? '';
const clientNamespaces = new Set([...clientBlock.matchAll(/'([\w-]+)'/g)].map((m) => m[1]));
if (localeCodes.length === 0 || !defaultLocale || clientNamespaces.size === 0) {
console.error('check-i18n: could not read LOCALES, DEFAULT_LOCALE or CLIENT_NAMESPACES from src/i18n/config.ts');
process.exit(2);
}
/*
* `locales.mjs` is the same list in plain JavaScript, for `astro.config.mjs` and this
* script, both of which run before TypeScript exists. Two lists is one more than
* ideal, so they are compared here rather than trusted.
*/
const { DEFAULT_LOCALE: mjsDefault, LOCALE_CODES: mjsCodes } = await import(
path.join(i18nDir, 'locales.mjs')
);
if (mjsDefault !== defaultLocale || mjsCodes.join(',') !== localeCodes.join(',')) {
console.error('check-i18n: src/i18n/config.ts and src/i18n/locales.mjs disagree.');
console.error(` config.ts: default ${defaultLocale}, locales [${localeCodes.join(', ')}]`);
console.error(` locales.mjs: default ${mjsDefault}, locales [${mjsCodes.join(', ')}]`);
console.error('Both need the new language. See README, "Adding a language".');
process.exit(2);
}
/* ---------- catalogs ---------- */
const catalogs = new Map();
for (const code of localeCodes) {
const file = path.join(i18nDir, `${code}.json`);
try {
catalogs.set(code, JSON.parse(await readFile(file, 'utf8')));
} catch (error) {
console.error(`check-i18n: cannot read src/i18n/${code}.json (${error.message})`);
console.error(`Every locale in LOCALES needs a catalog. Copy en.json to ${code}.json and translate it.`);
process.exit(2);
}
}
const base = catalogs.get(defaultLocale);
/** CLDR plural categories, which are a key's suffix rather than a key of their own. */
const PLURAL_SUFFIX = /\.(zero|one|two|few|many|other)$/;
/** `card.reviews.one` and `card.reviews` are the same key as far as coverage goes. */
const stem = (key) => key.replace(PLURAL_SUFFIX, '');
/**
* Every stem a catalog can resolve.
*
* Stems rather than literal keys, because a locale is allowed more plural categories
* than English has: Spanish spells `time.ago.month` as `.one` and `.other` where
* English needs one form, and neither is missing anything.
*/
const stems = (catalog) => new Set(Object.keys(catalog).map(stem));
const baseStems = stems(base);
const problems = [];
const warnings = [];
for (const code of localeCodes) {
if (code === defaultLocale) continue;
const catalog = catalogs.get(code);
const own = stems(catalog);
const missing = [...baseStems].filter((key) => !own.has(key)).sort();
const unknown = [...own].filter((key) => !baseStems.has(key)).sort();
if (missing.length > 0) {
(strict ? problems : warnings).push({
code,
kind: 'missing',
keys: missing,
note: `${missing.length} key(s) fall back to ${defaultLocale}`,
});
}
if (unknown.length > 0) {
problems.push({
code,
kind: 'unknown',
keys: unknown,
note: `${unknown.length} key(s) are not in ${defaultLocale}.json, so nothing reads them`,
});
}
}
/* ---------- the warning copy, which lives in two packages ---------- */
/*
* `shared/src/warnings.ts` decides which banner a mint gets and writes the English
* sentence for it, because the API's fixture tests assert on that sentence. The
* catalogs carry the same sentences so they can be translated. Two copies of safety
* copy is exactly the sort of thing that drifts, and a mint page and a mint card
* disagreeing about whether withdrawals work is the worst possible thing to get wrong,
* so the two are diffed here on every build.
*
* Read from shared's build output rather than its source: it is a prerequisite of the
* web build, so it is always there, and importing it beats parsing TypeScript.
*/
const SHARED_TABLES = [
{ module: 'WARNING_COPY_EN', prefix: 'mint.warnings.' },
{ module: 'CHIP_COPY_EN', prefix: 'mint.chip.' },
];
try {
const shared = await import(path.join(root, '../shared/dist/warnings.js'));
for (const { module, prefix } of SHARED_TABLES) {
const table = shared[module];
if (!table) {
problems.push({
code: defaultLocale,
kind: 'shared',
keys: [module],
note: `shared/dist/warnings.js no longer exports ${module}`,
});
continue;
}
const drift = [];
for (const [key, english] of Object.entries(table)) {
const catalogKey = prefix + key;
const inCatalog = base[catalogKey];
if (inCatalog === undefined) drift.push(`${catalogKey} (in ${module}, not in ${defaultLocale}.json)`);
else if (inCatalog !== english) {
drift.push(`${catalogKey}\n shared: ${english}\n catalog: ${inCatalog}`);
}
}
// And the other direction: a catalog key under this prefix that shared never writes
// is a key no banner will ever ask for.
for (const key of Object.keys(base)) {
if (!key.startsWith(prefix)) continue;
if (table[key.slice(prefix.length)] === undefined) {
drift.push(`${key} (in ${defaultLocale}.json, not in ${module})`);
}
}
if (drift.length > 0) {
problems.push({
code: defaultLocale,
kind: 'shared',
keys: drift,
note: `${drift.length} difference(s) between shared's ${module} and ${defaultLocale}.json`,
});
}
}
} catch (error) {
problems.push({
code: defaultLocale,
kind: 'shared',
keys: [String(error.message)],
note: 'could not read shared/dist/warnings.js. Run `pnpm --filter ./shared build` first',
});
}
/* ---------- key usage in the source ---------- */
async function walk(dir) {
const out = [];
for (const entry of await readdir(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'i18n' || entry.name === 'bones') continue;
out.push(...(await walk(full)));
} else if (/\.(astro|ts)$/.test(entry.name)) {
out.push(full);
}
}
return out;
}
/**
* `t('some.key')` and `t('some.key', { ... })`, with either quote.
*
* Only literal keys are found, which is the point: a key built at runtime cannot be
* checked, so the codebase does not build them. The two places that legitimately need
* a computed key (`nut.${n}` and `status.${mint.status}`) use a helper with its own
* exhaustive list, declared below.
*/
const T_CALL = /\bt\(\s*(['"])([\w.-]+)\1/g;
/**
* Key families built from a value at runtime, listed here so the checker knows they
* are reached. Each entry is a prefix; every catalog key under it counts as used.
*/
const DYNAMIC_PREFIXES = [
'nut.', // nutName(n)
'status.', // statusLabel(mint.status)
'reviews.dialog.stars.', // the star picker's phrase per rating
'reviews.breakdown.', // ratingBreakdown, one key per star count
'mint.warnings.', // warningStrings(), keyed by kind
'mint.chip.', // mintChip()
'login.method.', // methodChipHtml(session.method)
'wallets.platform.', // the platform list on each wallet card
'wallets.item.', // one blurb per wallet, keyed by its id
'login.connect.', // CONNECT_COPY, keyed by method
'time.ago.', // formatRelative, keyed by unit
'time.short.', // formatShortDuration, keyed by unit
];
const isDynamic = (key) => DYNAMIC_PREFIXES.some((prefix) => key.startsWith(prefix));
const files = await walk(srcDir);
const used = new Set();
const undefinedKeys = [];
const clientLeaks = [];
for (const file of files) {
const source = await readFile(file, 'utf8');
const relative = path.relative(root, file);
/*
* An .astro file is two programs: frontmatter and template render at build time, and
* anything inside a <script> ships to the browser. Only the second half is limited to
* the client namespaces, so the two are checked separately.
*/
const scripts = [...source.matchAll(/<script(?![^>]*\bis:inline\b)[^>]*>([\s\S]*?)<\/script>/g)]
.map((m) => m[1])
.join('\n');
// A .ts file under lib/ or scripts/ is island code wholesale.
const shipsToBrowser = /\/(lib|scripts)\//.test(relative) && relative.endsWith('.ts');
for (const match of source.matchAll(T_CALL)) {
const key = match[2];
used.add(key);
if (!baseStems.has(stem(key))) {
undefinedKeys.push({ key, file: relative });
}
}
const clientSource = shipsToBrowser ? source : scripts;
for (const match of clientSource.matchAll(T_CALL)) {
const key = match[2];
const namespace = key.split('.')[0];
if (!clientNamespaces.has(namespace)) {
clientLeaks.push({ key, file: relative, namespace });
}
}
}
if (undefinedKeys.length > 0) {
problems.push({
code: defaultLocale,
kind: 'undefined',
keys: undefinedKeys.map((u) => `${u.key} (${u.file})`),
note: `${undefinedKeys.length} key(s) used in the source are not defined in ${defaultLocale}.json`,
});
}
if (clientLeaks.length > 0) {
problems.push({
code: '-',
kind: 'client',
keys: clientLeaks.map((c) => `${c.key} (${c.file}, namespace "${c.namespace}")`),
note: `${clientLeaks.length} island key(s) outside CLIENT_NAMESPACES: add the namespace to config.ts or move the string`,
});
}
/* ---------- keys nothing reads ---------- */
const unused = [...baseStems]
.filter((key) => !used.has(key) && !isDynamic(key))
.filter((key) => !base[key] === false || true)
.sort();
/* ---------- report ---------- */
const report = (list, level) => {
for (const entry of list) {
console.log(`\n${level} [${entry.kind}] ${entry.code}: ${entry.note}`);
for (const key of entry.keys.slice(0, 40)) console.log(` ${key}`);
if (entry.keys.length > 40) console.log(` … and ${entry.keys.length - 40} more`);
}
};
const total = Object.keys(base).length;
console.log(
`check-i18n: ${localeCodes.length} locales, ${total} keys in ${defaultLocale}.json, ` +
`${used.size} literal key(s) used in src/.`,
);
if (process.env['LIST_UNUSED'] && unused.length > 0) {
console.log(`\n ${unused.length} key(s) with no literal or dynamic use:`);
for (const key of unused) console.log(` ${key}`);
}
report(warnings, 'WARN ');
report(problems, 'ERROR');
if (problems.length > 0) {
console.log(`\ncheck-i18n failed with ${problems.length} problem group(s).`);
process.exit(1);
}
if (warnings.length > 0) {
console.log('\ncheck-i18n passed with warnings. Untranslated keys fall back to English.');
} else {
console.log('Every locale is complete and every key used in the source is defined.');
}
+154
View File
@@ -0,0 +1,154 @@
/**
* Internal link checker for the built site.
*
* Walks every .html file in dist/, pulls out every href and src that stays on this
* origin, and resolves it against what the build actually emitted. Three failures are
* reported, all of them things a visitor would hit:
*
* missing the target route was never emitted (a 404 on a real link)
* anchor the route exists but the #id it points at is not in that page
* asset a referenced local file (css, js, image) is not in dist/
*
* Query strings are stripped before resolving: /mints?sort=rating is /mints. Links
* that leave the origin (http(s)://other, mailto:, wss:) are counted and skipped.
*
* Usage: node scripts/check-links.mjs [distDir]
* Exits non-zero when anything is broken, so it can gate a deploy.
*/
import { readdir, readFile, stat } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
const dist = path.resolve(process.argv[2] ?? 'dist');
if (!existsSync(dist)) {
console.error(`No such directory: ${dist}. Run \`pnpm build\` first.`);
process.exit(2);
}
/** Every file under dir, as paths relative to dir with forward slashes. */
async function walk(dir, base = dir) {
const out = [];
for (const entry of await readdir(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) out.push(...(await walk(full, base)));
else out.push(path.relative(base, full).split(path.sep).join('/'));
}
return out;
}
const files = await walk(dist);
const fileSet = new Set(files);
const pages = files.filter((f) => f.endsWith('.html'));
/**
* Routes a visitor can reach. Astro writes /mints as mints/index.html, and the site
* runs with trailingSlash: 'ignore', so both spellings resolve.
*/
/*
* `/404` counts as a route.
*
* It used to be skipped, because nothing linked to it and it exists to be served by
* `error_page` rather than navigated to. The language switcher changed that: on a 404,
* every language's version of the 404 is a real destination, and the nginx block in the
* README resolves `/404` through its `$uri.html` rule exactly as it resolves the
* prefixed ones through their directory index.
*/
const routes = new Set();
for (const file of pages) {
const route = file === 'index.html' ? '/' : '/' + file.replace(/\/index\.html$/, '').replace(/\.html$/, '');
routes.add(route);
if (route !== '/') routes.add(route + '/');
}
/** #ids present in each page, so anchor links can be checked against the real markup. */
const idsByRoute = new Map();
const HREF = /(?:href|src)\s*=\s*"([^"]*)"/gi;
const ASSET_EXT = /\.(css|js|mjs|json|xml|txt|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|otf|map|webmanifest|pdf|mp4|webm)$/i;
const ID = /\sid\s*=\s*"([^"]+)"/gi;
const problems = [];
const seen = new Map(); // target -> Set of pages linking to it
let external = 0;
for (const file of pages) {
const html = await readFile(path.join(dist, file), 'utf8');
const route = file === 'index.html' ? '/' : '/' + file.replace(/\/index\.html$/, '').replace(/\.html$/, '');
const ids = new Set();
for (const m of html.matchAll(ID)) ids.add(m[1]);
idsByRoute.set(route, ids);
idsByRoute.set(route + '/', ids);
for (const m of html.matchAll(HREF)) {
const raw = m[1].trim();
if (!raw || raw.startsWith('#') || raw.startsWith('data:')) continue;
if (/^[a-z][a-z0-9+.-]*:/i.test(raw) || raw.startsWith('//')) { external++; continue; }
if (!raw.startsWith('/')) continue; // relative asset paths: Astro does not emit any
const [pathPart, hashPart] = raw.split('#');
const target = (pathPart ?? '').split('?')[0] || '/';
const key = hashPart ? `${target}#${hashPart}` : target;
if (!seen.has(key)) seen.set(key, new Set());
seen.get(key).add(route);
// The API serves /api/* and /icons/* in production; nothing under them is in dist.
if (target.startsWith('/api/') || target.startsWith('/icons/')) continue;
const asFile = target.replace(/^\//, '');
// Extension based, from a fixed list: mint slugs are domains, so "ends in a dot
// and some letters" would call /mint/cashu.boats an asset and look for the file.
const isAsset = ASSET_EXT.test(asFile);
if (isAsset) {
if (!fileSet.has(asFile)) problems.push({ kind: 'asset', target, from: route });
continue;
}
if (!routes.has(target)) {
problems.push({ kind: 'missing', target, from: route });
continue;
}
if (hashPart) {
const known = idsByRoute.get(target);
// The target page may not be parsed yet: anchors are re-checked in a second pass.
if (known && !known.has(hashPart)) problems.push({ kind: 'anchor', target: `${target}#${hashPart}`, from: route });
}
}
}
// Second pass for anchors whose target page had not been read yet on the first pass.
for (const [key, froms] of seen) {
if (!key.includes('#')) continue;
const [target, hash] = key.split('#');
if (!routes.has(target)) continue;
const ids = idsByRoute.get(target);
if (!ids || ids.has(hash)) continue;
for (const from of froms) {
if (!problems.some((p) => p.kind === 'anchor' && p.target === key && p.from === from)) {
problems.push({ kind: 'anchor', target: key, from });
}
}
}
const internalTargets = [...seen.keys()].sort();
console.log(`Checked ${pages.length} pages, ${routes.size / 1} route spellings, ${internalTargets.length} distinct internal targets (${external} external links skipped).`);
if (process.env['LIST_TARGETS']) {
for (const target of internalTargets) {
const froms = [...seen.get(target)].sort();
const shown = froms.length > 4 ? `${froms.slice(0, 4).join(', ')} +${froms.length - 4} more` : froms.join(', ');
console.log(` ${target.padEnd(34)} <- ${shown}`);
}
}
if (problems.length === 0) {
console.log('No broken internal links.');
process.exit(0);
}
const label = { missing: 'route not emitted', anchor: 'no such id on the target page', asset: 'file not in dist' };
console.log(`\n${problems.length} problem(s):`);
for (const p of problems) console.log(` [${label[p.kind]}] ${p.target} linked from ${p.from}`);
process.exit(1);
+29
View File
@@ -0,0 +1,29 @@
/**
* The build's front door to `check-i18n.mjs`.
*
* Imported by `astro.config.mjs` so a catalog problem is found before a single page
* renders, rather than after a two-minute build produces 180 pages with a raw key on
* them. Errors throw and stop the build; missing keys print and let it continue.
*/
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
export async function checkCatalogs() {
const result = spawnSync(process.execPath, [path.join(here, 'check-i18n.mjs')], {
encoding: 'utf8',
});
// Warnings and the summary go straight through, so a missing-key list is visible in
// the build log where whoever is adding a language will actually read it.
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
if (result.status !== 0) {
throw new Error(
'check-i18n failed. Fix the keys listed above, or run `pnpm check:i18n` to see them again.',
);
}
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Load the workspace .env into process.env.
*
* The API gets this from node's own --env-file-if-exists, but astro is launched by its
* own binary, so the web side needs it done in-process. astro.config.mjs imports this
* first, before it reads anything off process.env, and the build-time consumers
* (web/src/lib/api.ts, web/src/lib/nostr-build.ts) run in the same process afterwards.
*
* Matching node's semantics: a variable already present in the real environment is left
* alone, so `PORT=9000 pnpm dev` and systemd Environment= still win over the file.
*/
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
/** Repo root first, then a web-local override if someone wants one. */
const files = [path.join(webRoot, '..', '.env'), path.join(webRoot, '.env')];
const LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/;
function unquote(value) {
const quote = value[0];
if ((quote === '"' || quote === "'") && value.at(-1) === quote && value.length > 1) {
const inner = value.slice(1, -1);
return quote === '"' ? inner.replace(/\\n/g, '\n').replace(/\\"/g, '"') : inner;
}
// Unquoted: an inline comment ends the value.
return value.split(/\s+#/)[0].trim();
}
for (const file of files) {
let text;
try {
text = readFileSync(file, 'utf8');
} catch {
continue; // No .env is a supported setup — every variable has a default.
}
for (const line of text.split('\n')) {
if (!line.trim() || line.trim().startsWith('#')) continue;
const match = LINE.exec(line);
if (!match) continue;
const [, name, raw] = match;
if (name in process.env) continue;
process.env[name] = unquote(raw);
}
}
+183
View File
@@ -0,0 +1,183 @@
/**
* Generates the social card and the icon set into `public/`.
*
* Run by hand after a brand change; the output is committed. This is deliberately not
* wired into `pnpm build` — the site must build on a machine with no browser, and these
* files change roughly never.
*
* node scripts/make-assets.mjs
*
* Playwright and sharp are not dependencies of this workspace. Both arrive transitively
* under `astro` (which ships sharp for its image pipeline) and are resolved through it,
* the same trick `bones.mjs` relies on for its capture browser. If that ever stops
* working, `pnpm dlx playwright@1` and `pnpm dlx sharp-cli` do the same job.
*
* The card is drawn in a real browser rather than assembled by hand because it uses the
* site's own fonts and tokens: a social card that does not look like the site is worth
* less than no card. The moai is the same rect geometry as `components/Moai.astro`.
*/
import { createRequire } from 'node:module';
import { mkdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const publicDir = path.join(root, 'public');
const fontsDir = path.join(root, 'src', 'assets', 'fonts');
// astro's entry point; requiring from there reaches what pnpm hoisted under it.
const require = createRequire(import.meta.resolve('astro'));
const { chromium } = require('playwright');
const sharp = require('sharp');
/* The pixel moai, identical to components/Moai.astro. viewBox is 11x13. */
const MOAI_RECTS = [
[2, 0, 7, 1, '#C9BFA4'],
[1, 1, 9, 2, '#C9BFA4'],
[1, 3, 2, 7, '#A89B7C'],
[3, 3, 6, 7, '#C9BFA4'],
[3, 4, 2, 1, '#5C5340'],
[7, 4, 2, 1, '#5C5340'],
[5, 5, 1, 3, '#A89B7C'],
[2, 10, 8, 2, '#A89B7C'],
];
const moaiSvg = (width) =>
`<svg width="${width}" height="${Math.round((width / 11) * 13)}" viewBox="0 0 11 13" ` +
`shape-rendering="crispEdges" xmlns="http://www.w3.org/2000/svg">` +
MOAI_RECTS.map(
([x, y, w, h, fill]) => `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${fill}"/>`,
).join('') +
`</svg>`;
const fontFace = (family, weight, file) => `
@font-face {
font-family: '${family}';
font-style: normal;
font-weight: ${weight};
src: url('file://${path.join(fontsDir, file)}') format('woff2');
}`;
/* The 1200x630 card. Design tokens copied from styles/global.css. */
const cardHtml = `<!doctype html><html><head><meta charset="utf-8"><style>
${fontFace('Space Grotesk', 700, 'space-grotesk-latin.woff2')}
${fontFace('Inter', 400, 'inter-latin.woff2')}
${fontFace('JetBrains Mono', 400, 'jetbrains-mono-latin.woff2')}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1200px; height: 630px; background: #0E0D0B; color: #EDE8DC;
font-family: 'Inter', sans-serif; display: flex; flex-direction: column;
justify-content: center; padding: 0 88px; position: relative; overflow: hidden;
}
/* The same warm glow the hero carries, kept subtle so text stays legible. */
body::after {
content: ''; position: absolute; inset: auto -200px -320px auto;
width: 720px; height: 720px; border-radius: 50%;
background: radial-gradient(circle, rgba(240,169,59,.16), transparent 68%);
}
.brand { display: flex; align-items: center; gap: 18px; margin-bottom: 40px; }
.brand span {
font-family: 'Space Grotesk', sans-serif; font-weight: 700; font-size: 34px;
letter-spacing: -.01em;
}
h1 {
font-family: 'Space Grotesk', sans-serif; font-weight: 700; font-size: 76px;
line-height: 1.06; letter-spacing: -.03em; max-width: 15ch;
}
h1 em { font-style: normal; color: #F0A93B; }
p { margin-top: 28px; font-size: 27px; line-height: 1.45; color: #9A927E; max-width: 34ch; }
.rule { margin-top: 44px; width: 132px; height: 5px; background: #F0A93B; border-radius: 3px; }
.host {
position: absolute; bottom: 54px; right: 88px;
font-family: 'JetBrains Mono', monospace; font-size: 21px; color: #6B6555;
}
</style></head><body>
<div class="brand">${moaiSvg(40)}<span>Cashumints.space</span></div>
<h1>Find a Cashu mint <em>you can trust</em></h1>
<p>Every mint on the Nostr network, with community reviews you can verify yourself.</p>
<div class="rule"></div>
<div class="host">cashumints.space</div>
</body></html>`;
/* The icon master: the moai on the site's background, generous padding. */
const iconHtml = `<!doctype html><html><head><meta charset="utf-8"><style>
* { margin: 0; padding: 0; }
body {
width: 512px; height: 512px; background: #0E0D0B;
display: flex; align-items: center; justify-content: center;
}
</style></head><body>${moaiSvg(320)}</body></html>`;
/**
* A 32x32 ICO wrapping a PNG payload.
*
* sharp cannot write ICO and the format needs no library for this case: the container
* is a 6-byte directory header, one 16-byte entry, then the PNG bytes verbatim. Worth
* having because bare `/favicon.ico` requests from crawlers and feed readers still
* happen, and a 404 there is noise in the log for no reason.
*/
function icoFromPng(png) {
const header = Buffer.alloc(6);
header.writeUInt16LE(0, 0); // reserved
header.writeUInt16LE(1, 2); // type: icon
header.writeUInt16LE(1, 4); // one image
const entry = Buffer.alloc(16);
entry[0] = 32; // width
entry[1] = 32; // height
entry[2] = 0; // palette size: 0 means truecolour
entry[3] = 0; // reserved
entry.writeUInt16LE(1, 4); // colour planes
entry.writeUInt16LE(32, 6); // bits per pixel
entry.writeUInt32LE(png.length, 8);
entry.writeUInt32LE(header.length + entry.length, 12);
return Buffer.concat([header, entry, png]);
}
await mkdir(publicDir, { recursive: true });
const browser = await chromium.launch();
try {
const page = await browser.newPage({
viewport: { width: 1200, height: 630 },
deviceScaleFactor: 1,
});
await page.setContent(cardHtml, { waitUntil: 'load' });
await page.evaluate(() => document.fonts.ready);
await page.screenshot({ path: path.join(publicDir, 'og.png') });
console.log('og.png');
await page.setViewportSize({ width: 512, height: 512 });
await page.setContent(iconHtml, { waitUntil: 'load' });
const master = await page.screenshot({ type: 'png' });
for (const [name, size] of [
['icon-512.png', 512],
['icon-192.png', 192],
['favicon-32.png', 32],
]) {
await sharp(master).resize(size, size).png({ compressionLevel: 9 }).toFile(path.join(publicDir, name));
console.log(name);
}
// iOS ignores transparency and composites on white, so flatten onto the brand ground.
await sharp(master)
.resize(180, 180)
.flatten({ background: '#0E0D0B' })
.png({ compressionLevel: 9 })
.toFile(path.join(publicDir, 'apple-touch-icon.png'));
console.log('apple-touch-icon.png');
const png32 = await sharp(master).resize(32, 32).png({ compressionLevel: 9 }).toBuffer();
await writeFile(path.join(publicDir, 'favicon.ico'), icoFromPng(png32));
console.log('favicon.ico');
// The standalone SVG favicon: the same mark, at any size, for browsers that take it.
await writeFile(path.join(publicDir, 'favicon.svg'), moaiSvg(11));
console.log('favicon.svg');
} finally {
await browser.close();
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+326
View File
@@ -0,0 +1,326 @@
{
"breakpoints": {
"360": {
"name": "mint-page",
"viewportWidth": 324,
"width": 324,
"height": 327,
"bones": [
[
0,
7,
16.0494,
52,
13
],
[
20.9877,
0,
59.2593,
43,
8
],
[
20.9877,
43,
59.2593,
22,
8
],
[
0,
81,
23.7654,
29,
999,
true
],
[
3.7037,
92,
2.1605,
7,
"50%"
],
[
0,
128,
35.7301,
17,
8
],
[
13.2716,
159,
22.4585,
18,
8
],
[
43.7548,
128,
17.5733,
17,
8
],
[
43.7548,
149,
12.037,
31,
8
],
[
0,
209,
46.2963,
17,
8
],
[
0,
227,
46.2963,
21,
8
],
[
54.321,
209,
25.2749,
17,
8
],
[
54.321,
227,
20.3704,
21,
8
],
[
0,
265,
100,
63,
8
]
]
},
"768": {
"name": "mint-page",
"viewportWidth": 712,
"width": 712,
"height": 193,
"bones": [
[
0,
7,
7.3034,
52,
13
],
[
9.5506,
0,
26.9663,
43,
8
],
[
9.5506,
43,
26.9663,
22,
8
],
[
38.764,
18,
10.8146,
29,
999,
true
],
[
40.4494,
29,
0.9831,
7,
"50%"
],
[
0,
83,
16.2592,
17,
8
],
[
6.0393,
113,
10.2199,
18,
8
],
[
19.9109,
83,
7.9968,
17,
8
],
[
19.9109,
103,
5.4775,
31,
8
],
[
31.5594,
83,
21.0674,
17,
8
],
[
31.5594,
101,
21.0674,
21,
8
],
[
56.2785,
83,
11.5015,
17,
8
],
[
56.2785,
101,
9.2697,
21,
8
],
[
0,
151,
100,
42,
8
]
]
},
"1280": {
"name": "mint-page",
"viewportWidth": 1184,
"width": 1184,
"height": 172,
"bones": [
[
0,
7,
4.3919,
52,
13
],
[
5.7432,
0,
16.2162,
43,
8
],
[
5.7432,
43,
16.2162,
22,
8
],
[
23.3108,
18,
6.5034,
29,
999,
true
],
[
24.3243,
29,
0.5912,
7,
"50%"
],
[
0,
83,
9.7775,
17,
8
],
[
3.6318,
113,
6.1457,
18,
8
],
[
11.9734,
83,
4.8089,
17,
8
],
[
11.9734,
103,
3.2939,
31,
8
],
[
18.9783,
83,
12.6689,
17,
8
],
[
18.9783,
101,
12.6689,
21,
8
],
[
33.8432,
83,
6.9164,
17,
8
],
[
33.8432,
101,
5.5743,
21,
8
],
[
0,
151,
100,
21,
8
]
]
}
},
"_hash": "f3f7f7e86a10ecca631b74af17fb0335"
}
+407
View File
@@ -0,0 +1,407 @@
{
"breakpoints": {
"360": {
"name": "reviews-panel",
"viewportWidth": 322,
"width": 322,
"height": 727,
"bones": [
[
6.2112,
20,
11.8012,
38,
"50%"
],
[
22.9814,
19,
32.6087,
22,
8
],
[
22.9814,
41,
70.8075,
16,
5
],
[
22.9814,
70,
20.429,
16,
8
],
[
46.5159,
68,
11.8012,
19,
8
],
[
22.9814,
98,
70.8075,
116,
8
],
[
6.2112,
255,
11.8012,
38,
"50%"
],
[
22.9814,
254,
23.6025,
22,
8
],
[
22.9814,
277,
70.8075,
16,
5
],
[
22.9814,
305,
20.429,
16,
8
],
[
46.5159,
303,
12.1118,
19,
8
],
[
22.9814,
333,
70.8075,
116,
8
],
[
6.2112,
490,
11.8012,
38,
"50%"
],
[
22.9814,
489,
31.0559,
22,
8
],
[
22.9814,
512,
70.8075,
16,
5
],
[
22.9814,
540,
20.429,
16,
8
],
[
46.5159,
538,
12.7329,
19,
8
],
[
22.9814,
568,
70.8075,
139,
8
]
]
},
"768": {
"name": "reviews-panel",
"viewportWidth": 710,
"width": 710,
"height": 455,
"bones": [
[
2.8169,
20,
5.3521,
38,
"50%"
],
[
10.4225,
21,
14.7887,
22,
8
],
[
10.4225,
43,
75.2421,
16,
5
],
[
87.9181,
22,
9.265,
16,
8
],
[
91.831,
42,
5.3521,
19,
8
],
[
10.4225,
77,
86.7606,
46,
8
],
[
2.8169,
164,
5.3521,
38,
"50%"
],
[
10.4225,
165,
10.7042,
22,
8
],
[
10.4225,
187,
75.2421,
16,
5
],
[
87.9181,
166,
9.265,
16,
8
],
[
91.6901,
186,
5.493,
19,
8
],
[
10.4225,
221,
86.7606,
46,
8
],
[
2.8169,
308,
5.3521,
38,
"50%"
],
[
10.4225,
309,
14.0845,
22,
8
],
[
10.4225,
331,
75.2421,
16,
5
],
[
87.9181,
310,
9.265,
16,
8
],
[
91.4085,
330,
5.7746,
19,
8
],
[
10.4225,
365,
86.7606,
70,
8
]
]
},
"1280": {
"name": "reviews-panel",
"viewportWidth": 784,
"width": 784,
"height": 431,
"bones": [
[
2.551,
20,
4.8469,
38,
"50%"
],
[
9.4388,
21,
13.3929,
22,
8
],
[
9.4388,
43,
77.5789,
16,
5
],
[
89.0585,
22,
8.3905,
16,
8
],
[
92.602,
42,
4.8469,
19,
8
],
[
9.4388,
77,
88.0102,
46,
8
],
[
2.551,
164,
4.8469,
38,
"50%"
],
[
9.4388,
165,
9.6939,
22,
8
],
[
9.4388,
187,
77.5789,
16,
5
],
[
89.0585,
166,
8.3905,
16,
8
],
[
92.4745,
186,
4.9745,
19,
8
],
[
9.4388,
221,
88.0102,
46,
8
],
[
2.551,
308,
4.8469,
38,
"50%"
],
[
9.4388,
309,
12.7551,
22,
8
],
[
9.4388,
331,
77.5789,
16,
5
],
[
89.0585,
310,
8.3905,
16,
8
],
[
92.2194,
330,
5.2296,
19,
8
],
[
9.4388,
365,
88.0102,
46,
8
]
]
}
},
"_hash": "788093c50463a4ad673ece50c6db52da"
}
+122
View File
@@ -0,0 +1,122 @@
---
/**
* Island: the animated colour field behind the hero.
*
* The renderer is in scripts/color-bends.ts; this is the layer it draws into and the
* fallback for everyone who never reaches it. The canvas is decorative, so it is
* hidden from assistive technology and takes no pointer events: the hero's search box
* sits on top of it and has to stay clickable.
*
* With JavaScript off, without WebGL, or in the moment before the first frame, the
* gradient below is all there is — the same colour in the same place, holding still.
* The hero never renders as a blank band.
*/
import type { ColorBendsOptions } from '../scripts/color-bends';
interface Props extends ColorBendsOptions {
/** Opacity of the whole layer. The shader parameters stay as tuned; this is the mix. */
opacity?: number;
class?: string;
}
const { opacity = 0.55, class: className, ...bends } = Astro.props;
/**
* Serialised for the module script, which is a separate file and cannot read props.
* The renderer's own defaults fill in anything not passed here.
*/
const config = JSON.stringify(bends);
const first = bends.colors?.[0] ?? '#A78BFA';
---
<div class={['color-bends', className].filter(Boolean).join(' ')} style={`--cb-opacity:${opacity};--cb-tint:${first}`}>
<canvas data-color-bends data-config={config} aria-hidden="true"></canvas>
</div>
<style>
.color-bends {
position: absolute;
inset: 0;
overflow: hidden;
/* Decoration, behind the hero's own content and deaf to the pointer. */
z-index: 0;
pointer-events: none;
opacity: var(--cb-opacity);
/*
* The field is a wash, not a panel: it fades out before it reaches any edge, so
* there is no rectangle for the eye to find and no seam where it meets the pulse
* strip below. Both axes, multiplied together by the two mask layers.
*/
-webkit-mask-image:
linear-gradient(to bottom, transparent, #000 22%, #000 62%, transparent 100%),
linear-gradient(to right, transparent, #000 18%, #000 82%, transparent);
-webkit-mask-composite: source-in;
mask-image:
linear-gradient(to bottom, transparent, #000 22%, #000 62%, transparent 100%),
linear-gradient(to right, transparent, #000 18%, #000 82%, transparent);
mask-composite: intersect;
}
/*
* The base wash, under the canvas and never removed: the bands are sparse at this
* scale and it is what fills the space between them. It doubles as the fallback,
* which is why nothing has to be swapped in when there is no WebGL — what is left is
* this, and it reads as a deliberate glow rather than a hole. `--cb-tint` is the
* first colour passed to the shader, so the two agree without repeating the hex.
*/
.color-bends::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(120% 80% at 50% 18%, var(--cb-tint), transparent 62%);
opacity: .5;
}
.color-bends canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
</style>
<script>
import { createColorBends, type ColorBendsOptions, type ColorBendsHandle } from '../scripts/color-bends';
import { onLeave, onReady } from '../scripts/reveal';
const setup = () => {
const canvas = document.querySelector<HTMLCanvasElement>('[data-color-bends]');
if (!canvas) return;
let options: ColorBendsOptions = {};
try {
options = JSON.parse(canvas.dataset.config ?? '{}') as ColorBendsOptions;
} catch {
// Unreadable config is not worth a dead hero: the renderer's defaults will do.
}
let handle: ColorBendsHandle | null = null;
/*
* One frame's delay. The hero's own entrance is the first thing that happens on
* this page and compiling a shader on the same frame stutters it; nothing is
* missing in the meantime, because the gradient fallback is already on screen.
*/
const armed = requestAnimationFrame(() => {
handle = createColorBends(canvas, options);
});
/*
* The router replaces this page's DOM but not its JavaScript. Without this the
* context outlives its canvas, and browsers drop the oldest live context once
* enough of them pile up — which would blank a hero somebody navigated back to.
*/
onLeave(() => {
cancelAnimationFrame(armed);
handle?.destroy();
handle = null;
});
};
onReady(setup);
</script>
+43
View File
@@ -0,0 +1,43 @@
---
/**
* A click to copy identifier.
*
* Every npub, pubkey and long key on the site renders through this, so they all
* behave the same: shortened on screen, full value in `title`, full value on the
* clipboard, and a "Copied" confirmation for a moment after. It is a real button, so
* it is reachable and operable from the keyboard and takes the global focus ring.
*
* The island rendered equivalent is `copyableIdHtml()` in lib/client.ts, which emits
* the same markup; one delegated handler drives both.
*/
import { useI18n } from '../i18n';
import { pageLocale } from '../i18n/paths';
interface Props {
/** The full value that goes on the clipboard. */
value: string;
/** What is shown. Defaults to the value itself. */
label?: string;
/**
* Named in the accessible label: "npub", "pubkey", "Nostr address". Already
* translated by the caller, which knows which kind of thing it is handing over.
*/
kind?: string;
class?: string;
}
const { locale } = pageLocale(Astro);
const t = useI18n(locale);
const { value, label, kind = t('mint.kind.identifier'), class: className } = Astro.props;
---
<button
type="button"
class:list={['cid', className]}
data-copy-id={value}
title={value}
aria-label={t('common.copyFull', { kind })}
>
<span class="cid-text">{label ?? value}</span>
</button>
+68
View File
@@ -0,0 +1,68 @@
---
import LanguageSwitcher from './LanguageSwitcher.astro';
import { localePath, useI18n } from '../i18n';
import { pageLocale } from '../i18n/paths';
const year = new Date().getFullYear();
const GITHUB = 'https://github.com/Azzamo-net/cashumints.space';
const { locale } = pageLocale(Astro);
const t = useI18n(locale);
/**
* Two rows: the pages people navigate to, then the ones they go looking for. The
* disclaimer sits in the second row here and again at the bottom of every mint page,
* which is where the decision it is about actually gets made.
*/
const nav = [
{ path: '/mints', label: t('footer.allMints') },
{ path: '/reviews', label: t('footer.allReviews') },
{ path: '/wallets', label: t('footer.wallets') },
{ path: '/about', label: t('footer.about') },
];
const legal = [
{ path: '/disclaimer', label: t('footer.disclaimer') },
{ path: '/terms', label: t('footer.terms') },
{ path: '/privacy', label: t('footer.privacy') },
];
/*
* One sentence with a link in the middle of it, so the whole thing is one string a
* translator can reorder. The only interpolated value is markup this file wrote, and
* the label inside it is a name, so there is nothing here that could come from a mint,
* a relay or a reader.
*/
const madeBy = t('footer.madeBy', {
azzamo: '<a href="https://azzamo.net" rel="noopener">Azzamo</a>',
});
---
<footer>
<div class="foot-inner">
<span>{t('footer.copyright', { year: String(year) })}</span>
<div class="links">
<a href={GITHUB} rel="noopener">GitHub</a>
{nav.map((link) => <a href={localePath(link.path, locale)}>{link.label}</a>)}
</div>
<span class="foot-right" set:html={madeBy} />
</div>
<div class="foot-inner foot-legal">
<div class="links">
{legal.map((link) => <a href={localePath(link.path, locale)}>{link.label}</a>)}
</div>
<LanguageSwitcher variant="inline" />
<span class="foot-right foot-warn">{t('footer.warning')}</span>
</div>
</footer>
<style>
/* The second row is quieter than the first and has no top border of its own: it
reads as a continuation of the footer, not a second footer. */
.foot-legal { padding-top: 0; border-top: none; font-size: 12.5px; }
.foot-warn { color: var(--faint); }
@media (max-width: 640px) {
.foot-legal { padding-top: 4px; }
}
</style>
+225
View File
@@ -0,0 +1,225 @@
---
/**
* The language switcher, in two shapes.
*
* `menu` is the header control: a compact button showing the current language's code,
* opening a list of the three. `inline` is the footer's plain row of three links. Both
* are built from the same list and the same hrefs, because they are the same control
* and a reader who finds one and then the other should not have to work out whether
* they do the same thing.
*
* Text, not flags, in both. A flag is a country: Spanish is not Spain, Dutch is not the
* Netherlands, and English belongs to nobody in particular. Each language is written in
* its own name, because someone looking for Nederlands is not scanning for "Dutch".
*
* Every link points at this same page in that language, built from the unprefixed path,
* so this works identically on a mint page: /mint/kashu.me and /es/mint/kashu.me are
* the same mint. There is no "switch to Spanish and land on the home page" case, which
* is the usual failure of a switcher that only knows about the site root.
*
* The header shape is a `<details>` rather than a scripted dropdown. It opens, closes
* and takes the keyboard with no JavaScript at all, which matters because the whole
* point of this control is being able to leave a page you cannot read. The script below
* only adds what `<details>` does not do by itself: closing on Escape and on a click
* elsewhere.
*/
import { LOCALES, localePath, useI18n } from '../i18n';
import { pageLocale } from '../i18n/paths';
interface Props {
/** `menu` is the header control; `inline` is the footer row. */
variant?: 'menu' | 'inline';
}
const { variant = 'inline' } = Astro.props;
const { locale, path } = pageLocale(Astro);
const t = useI18n(locale);
const links = LOCALES.map((other) => ({
code: other.code,
label: other.label,
href: localePath(path, other.code),
current: other.code === locale,
}));
/*
* Two letters in the header, because the full name of the current language is a word
* the reader already knows they are reading. The code is the locale's own, uppercased,
* so a fourth language needs nothing added here.
*/
const currentCode = locale.toUpperCase();
---
{
variant === 'menu' ? (
<details class="lang-menu" data-lang-menu>
<summary aria-haspopup="true">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18M12 3a15 15 0 0 1 4 9 15 15 0 0 1-4 9 15 15 0 0 1-4-9 15 15 0 0 1 4-9Z" />
</svg>
{/* The code alone does not say what it is, so the label is in the tree for a
screen reader and out of the way for everyone else. */}
<span class="sr-only">{t('lang.label')}</span>
<span class="lang-code">{currentCode}</span>
<svg class="lang-caret" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<path d="m6 9 6 6 6-6" />
</svg>
</summary>
<nav class="lang-list" aria-label={t('lang.switcher')}>
{
links.map((other) => (
<a
href={other.href}
hreflang={other.code}
lang={other.code}
aria-current={other.current ? 'true' : undefined}
>
<span>{other.label}</span>
{other.current && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
<path d="m5 13 4 4L19 7" />
</svg>
)}
</a>
))
}
</nav>
</details>
) : (
<nav class="lang-switch" aria-label={t('lang.switcher')}>
{
/*
* The current language is still a link, and it goes where it says it goes.
* `aria-current` is what tells a screen reader it is where you already are, and
* the weight is what tells everyone else.
*/
links.map((other) => (
<a
href={other.href}
hreflang={other.code}
lang={other.code}
aria-current={other.current ? 'true' : undefined}
>
{other.label}
</a>
))
}
</nav>
)
}
<style>
/* ---------- the header control ---------- */
.lang-menu { position: relative; flex-shrink: 0; }
.lang-menu summary {
display: flex; align-items: center; gap: 6px;
padding: 7px 10px; border-radius: 8px;
color: var(--muted); font-size: 13.5px; font-weight: 500;
cursor: pointer; list-style: none; user-select: none;
transition: color .15s, background .15s;
}
/* The default disclosure triangle, in both engines that draw one. */
.lang-menu summary::-webkit-details-marker { display: none; }
.lang-menu summary::marker { content: ''; }
.lang-menu summary:hover { color: var(--text); background: var(--card2); }
.lang-menu[open] summary { color: var(--text); background: var(--card2); }
.lang-code { font-family: var(--mono); font-size: 12.5px; letter-spacing: .04em; }
.lang-caret { color: var(--faint); transition: transform var(--dur-quick) var(--ease-out); }
.lang-menu[open] .lang-caret { transform: rotate(180deg); }
/* The same panel the account menu uses, so the two controls beside each other in the
header open into the same shape. */
.lang-list {
position: absolute; top: calc(100% + 8px); right: 0; z-index: 60;
min-width: 168px;
background: var(--card); border: 1px solid var(--line); border-radius: 12px;
padding: 6px; display: flex; flex-direction: column; gap: 2px;
box-shadow: 0 18px 40px rgba(0, 0, 0, .45);
}
.lang-list a {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px; border-radius: 8px;
font-size: 13.5px; color: var(--muted);
transition: color .15s, background .15s;
}
.lang-list a:hover { color: var(--text); background: var(--card2); }
.lang-list a[aria-current] { color: var(--text); font-weight: 600; }
.lang-list a svg { margin-left: auto; color: var(--amber); flex-shrink: 0; }
/*
* Narrow: the globe alone.
*
* Below 760px the header is already dropping things (the search affordance goes at
* the same breakpoint) and four nav items, an account control and this have to share
* one row. The globe is the part that makes the control findable; the code and the
* caret are the parts that only confirm what it is once you have found it. The
* accessible name stays either way, because it was never the visible text.
*/
@media (max-width: 760px) {
.lang-menu summary { padding: 7px 8px; gap: 0; }
.lang-code,
.lang-caret { display: none; }
}
@media (max-width: 420px) {
.lang-menu summary { padding: 6px 6px; }
}
/* ---------- the footer row ---------- */
.lang-switch { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.lang-switch a {
font-size: 12.5px; color: var(--faint);
transition: color var(--dur-quick);
}
.lang-switch a:hover { color: var(--text); }
.lang-switch a[aria-current] { color: var(--text); font-weight: 600; }
</style>
<script>
import { onReady } from '../scripts/reveal';
/**
* The two things `<details>` does not do on its own.
*
* Everything else about this control (open, close, keyboard, focus) is the element's
* own behaviour and works with this script blocked, which is the point of using one.
*
* Attached to the document once rather than to the element, because the view
* transition router replaces the header on every navigation and a per-element
* listener would be re-bound, and leak, once per page visited.
*/
let wired = false;
const closeAll = (except?: Element | null): void => {
for (const menu of document.querySelectorAll<HTMLDetailsElement>('[data-lang-menu][open]')) {
if (menu !== except) menu.open = false;
}
};
onReady(() => {
if (wired) return;
wired = true;
document.addEventListener('click', (event) => {
const target = event.target as HTMLElement | null;
// A click inside the open menu is either the summary (which toggles itself) or a
// link (which is about to navigate). Neither wants closing from here.
closeAll(target?.closest('[data-lang-menu]'));
});
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
const open = document.querySelector<HTMLDetailsElement>('[data-lang-menu][open]');
if (!open) return;
open.open = false;
// Back to the control that opened it, not to the top of the document.
open.querySelector('summary')?.focus();
});
});
</script>
+173
View File
@@ -0,0 +1,173 @@
---
/**
* The login popup, on every page because the header's "Log in" is on every page.
*
* Same shell as the review dialog and the mint QR: one modal shape on the site. It is
* complete markup, so the only thing the script does is decide which step is visible
* and fill in the QR once a pairing has actually started.
*
* Nothing here takes a key. The only text input is a connection link, and the code
* behind it refuses anything that looks like a key before reading it.
*/
import { localePath, useI18n } from '../i18n';
import { pageLocale } from '../i18n/paths';
const { locale } = pageLocale(Astro);
const t = useI18n(locale);
---
<dialog class="modal login-dialog" data-login-dialog aria-labelledby="login-title">
<div class="modal-body">
<div class="modal-head">
<h2 id="login-title" data-connect-title>{t('login.title')}</h2>
<button type="button" class="copy-btn" data-login-close aria-label={t('common.close')}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
<p class="modal-note">{t('login.note')}</p>
{/* Step one: pick how to sign. */}
<div data-login-step="choose">
<ul class="login-options">
<li>
<button type="button" class="login-option" data-login-method="extension">
<span class="login-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<path d="M10 3a2 2 0 0 1 4 0v1h3a1 1 0 0 1 1 1v3h1a2 2 0 0 1 0 4h-1v3a1 1 0 0 1-1 1h-3v-1a2 2 0 0 0-4 0v1H7a1 1 0 0 1-1-1v-3H5a2 2 0 0 1 0-4h1V5a1 1 0 0 1 1-1h3Z" />
</svg>
</span>
<span class="login-text">
<span class="login-name">{t('login.extension.name')}</span>
<span class="login-desc" data-extension-desc>{t('login.extension.desc')}</span>
</span>
<svg class="login-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="m9 6 6 6-6 6" />
</svg>
</button>
{/* Shown only when no extension answered, so the row still leads somewhere. */}
<p class="login-hint" data-extension-hint hidden>
{t('login.extension.getOne')}
<a href="https://getalby.com" rel="noopener noreferrer" target="_blank">Alby</a>
<span aria-hidden="true"> · </span>
<a href="https://github.com/fiatjaf/nos2x" rel="noopener noreferrer" target="_blank">nos2x</a>
</p>
</li>
<li>
<button type="button" class="login-option" data-login-method="remote">
<span class="login-icon">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
<rect x="3" y="3" width="7" height="7" rx="1.5" /><rect x="14" y="3" width="7" height="7" rx="1.5" />
<rect x="3" y="14" width="7" height="7" rx="1.5" /><path d="M14 14h3v3h-3zM20 14v3M14 20h6" />
</svg>
</span>
<span class="login-text">
<span class="login-name">{t('login.remote.name')}</span>
<span class="login-desc">{t('login.remote.desc')}</span>
</span>
<svg class="login-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="m9 6 6 6-6 6" />
</svg>
</button>
</li>
<li>
<button type="button" class="login-option" data-login-method="primal">
<span class="login-icon primal" aria-hidden="true">P</span>
<span class="login-text">
{/* A product name, not a label. */}
<span class="login-name">{t('login.primal.name')}</span>
<span class="login-desc">{t('login.primal.desc')}</span>
</span>
<svg class="login-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="m9 6 6 6-6 6" />
</svg>
</button>
</li>
</ul>
</div>
{/* Step two: a pairing code, for the remote and Primal routes alike. */}
<div data-login-step="connect" hidden>
<button type="button" class="login-back" data-login-back>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="m15 18-6-6 6-6" />
</svg>
{t('common.back')}
</button>
<p class="login-lead" data-connect-lead></p>
<div class="login-qr" data-connect-qr></div>
<div class="login-uri">
<span class="mono" data-connect-uri></span>
<button type="button" class="copy-btn" data-connect-copy data-copy="" aria-label={t('login.copyLink')} hidden>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<rect x="9" y="9" width="12" height="12" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" />
</svg>
</button>
</div>
<p class="login-hint" data-connect-hint></p>
{/*
Deliberately not a <form>.
A form here would be a form whose one field can be pasted into, and a browser
(or Astro's router, which handles form submissions for view transitions) would
put whatever was in it into the URL. This box only ever wants a connection
link, but the one thing that must never reach an address bar, a history entry
or a referrer is a key someone pasted into the wrong box. With no form there
is no submission and no query string, ever.
*/}
<div class="login-paste" data-bunker-form>
<label for="bunker-input">{t('login.paste.label')}</label>
<div class="login-paste-row">
<input
id="bunker-input"
name="bunker"
type="text"
inputmode="url"
autocomplete="off"
spellcheck="false"
placeholder="bunker://…"
/>
<button type="button" class="btn" data-bunker-connect>{t('common.connect')}</button>
</div>
</div>
</div>
<p class="login-status" data-login-status hidden role="status"></p>
<p class="login-error" data-login-error hidden role="alert"></p>
<p class="login-foot"><a href={localePath('/about', locale)}>{t('login.whatIsNostr')}</a></p>
</div>
</dialog>
<script>
import { initAuth, extensionAvailable } from '../lib/auth-ui';
import { useI18n } from '../i18n/client';
import { onReady } from '../scripts/reveal';
onReady(() => {
initAuth();
/*
* Whether an extension is installed is only knowable in the browser, and only
* after it has injected itself. The option is prerendered enabled and demoted
* here, rather than the other way round, so the common case never flickers.
*/
const option = document.querySelector<HTMLButtonElement>('[data-login-method="extension"]');
const desc = document.querySelector<HTMLElement>('[data-extension-desc]');
const hint = document.querySelector<HTMLElement>('[data-extension-hint]');
if (option && !extensionAvailable()) {
option.disabled = true;
if (desc) desc.textContent = useI18n()('login.extension.none');
if (hint) hint.hidden = false;
}
});
</script>
+177
View File
@@ -0,0 +1,177 @@
---
import { getMintWarnings, mintChip, type MintCapabilities, type MintListItem } from '@cashumints/shared';
import { iconUrl } from '../lib/api';
import { displayDomain, iconGradient, initials, transitionName } from '../lib/format';
import { localePath, useI18n } from '../i18n';
import { formatters, starString } from '../i18n/format';
import { chipStrings, statusLabel, warningOptions } from '../i18n/mint';
import { pageLocale } from '../i18n/paths';
interface Props {
mint: MintListItem;
rank?: number;
/** Sentiment split, when the caller has the distribution (home page top mints). */
sentiment?: { pos: number; neg: number };
/**
* Entrance delay in ms, for a grid of known size that arrives as one gesture (the
* home page's six). The long index at /mints leaves this unset and the reveal
* utility staggers whatever scrolls into view together instead, so a card in the
* fortieth row is not waiting on the position of the thirty-ninth.
*/
revealDelay?: number;
/**
* NUT-04 / NUT-05 switches, read from the mint's cached info by whoever fetched it.
* The list payload does not carry them, so a page that wants the chip reads them
* from the detail it already has. Left off, the card simply has no chip.
*/
capabilities?: MintCapabilities | null;
}
const { mint, rank, sentiment, revealDelay, capabilities } = Astro.props;
const { locale } = pageLocale(Astro);
const t = useI18n(locale);
const f = formatters(t);
const domain = displayDomain(mint.url);
const name = mint.name ?? domain.split('/')[0] ?? domain;
const icon = iconUrl(mint.icon);
const offline = mint.status === 'offline';
/*
Two words at most. Offline already has its own card styling, so only the states a
glance at the status cannot reveal earn a chip: a mint that takes deposits it
cannot pay back out, or one that has stopped moving sats in either direction. The
mint page carries the explanation.
*/
const chip = capabilities
? mintChip(
getMintWarnings(
{ status: mint.status, last_online: mint.last_online, capabilities },
warningOptions(f),
),
chipStrings(t),
)
: null;
const status = statusLabel(mint.status, t);
/*
* The mint's own name is never translated. It comes from the mint's /v1/info, and a
* mint called "Bolverker" is called that in every language. Same for the domain, which
* is an address.
*/
// Fall back to the rating split when no distribution was passed: a mint with a 4.6
// average is roughly 92% positive, which is what the bar is communicating.
const bar =
sentiment ??
(mint.rating_avg === null
? { pos: 0, neg: 0 }
: {
pos: Math.round(((mint.rating_avg - 1) / 4) * 100),
neg: 100 - Math.round(((mint.rating_avg - 1) / 4) * 100),
});
---
{/*
The icon and the name carry the view transition names their twins in the mint page
header carry, so clicking a card morphs them into place instead of cross-fading the
whole page over them.
They are held in data attributes rather than applied with `transition:name`, and
scripts/reveal.ts moves the one card being clicked onto the real property. A named
element is captured and animated separately, so naming all 58 cards on /mints would
hand the browser 116 textures to composite on every navigation, for 114 of which the
answer is "fade it out with everything else".
*/}
<a
class:list={['mint-card', { 'is-offline': offline }]}
href={localePath(`/mint/${mint.host}`, locale)}
data-reveal
data-reveal-delay={revealDelay}
data-mint-card
data-name={name.toLowerCase()}
data-domain={domain.toLowerCase()}
data-status={mint.status}
data-score={mint.score}
data-rating={mint.rating_avg ?? 0}
data-reviews={mint.review_count}
data-last-review={mint.last_review_at ?? 0}
data-last-online={mint.last_online ?? 0}
>
<div class="mc-top">
{
icon ? (
<img
class="mc-icon"
src={icon}
alt=""
width="42"
height="42"
loading="lazy"
decoding="async"
data-vt-icon={transitionName('icon', mint.host)}
/>
) : (
<span
class="mc-icon"
style={`background:${iconGradient(domain)}`}
aria-hidden="true"
data-vt-icon={transitionName('icon', mint.host)}
>
{initials(name)}
</span>
)
}
<span class="mc-id">
<span class="mc-name" data-vt-name={transitionName('name', mint.host)}>{name}</span>
<span class="mc-domain">{domain}</span>
</span>
{rank !== undefined && <span class:list={['mc-rank', { gold: rank === 1 }]}>#{rank}</span>}
</div>
<div class="mc-stats">
{
mint.rating_avg === null ? (
<span class="mc-none">{t('card.noRatings')}</span>
) : (
<span class="mc-rating">
{/* One decimal, with this locale's decimal separator: 4.6 and 4,6. */}
<span class="mc-score">{f.decimal(mint.rating_avg)}</span>
<span class="mc-stars" aria-hidden="true">{starString(mint.rating_avg)}</span>
</span>
)
}
<span class="mc-reviews">{t('card.reviews', { n: mint.review_count })}</span>
</div>
{/*
Both segments live in one wrapper so the bar grows from the card's left edge as a
single object (scaleX on the wrapper) rather than as two bars starting in two
places. The widths themselves never animate.
*/}
<div class="mc-bar" aria-hidden="true">
<span class="mc-bar-fill">
{bar.pos > 0 && <span class="pos" style={`width:${bar.pos}%`} />}
{bar.neg > 0 && <span class="neg" style={`width:${bar.neg}%`} />}
</span>
</div>
<div class="mc-foot">
<span class:list={['mc-dot', mint.status]}></span>
<span class="mc-status">{status}</span>
{chip && <span class:list={['mc-chip', chip.severity]}>{chip.label}</span>}
<span class="last">
{
offline
? mint.last_online
? t('card.lastSeen', { when: f.relative(mint.last_online) })
: t('card.neverSeen')
: mint.last_review_at
? t('card.reviewed', { when: f.relative(mint.last_review_at) })
: t('card.noReviews')
}
</span>
</div>
</a>
+144
View File
@@ -0,0 +1,144 @@
---
/**
* Island: refresh the mint page's status chip, its "checked" line and its warning
* banner from a fresh probe.
*
* Renders nothing. The prerendered markup already holds every value; this only
* updates them in place, so there is no layout shift and no blank state if the API
* is unreachable.
*
* The banner, the limits cell and the NUT rows are rebuilt from lib/mint-state.ts,
* the same code that prerendered them, so a live upgrade can never leave the three
* disagreeing with each other.
*/
interface Props {
host: string;
}
const { host } = Astro.props;
---
<span data-mint-live={host} hidden></span>
<script>
import { getMintWarnings, readCapabilities, type MintWarning } from '@cashumints/shared';
import { apiBase } from '../lib/client';
import { bannerHtml, limitsHtml, nutRowsHtml } from '../lib/mint-state';
import { useI18n } from '../i18n/client';
import { formatters, type Formatters } from '../i18n/format';
import { statusLabel, warningOptions } from '../i18n/mint';
import { onReady, prefersReducedMotion } from '../scripts/reveal';
import type { MintDetail } from '@cashumints/shared';
function remove(el: Element): void {
el.classList.add('leaving');
window.setTimeout(() => el.remove(), 140);
}
/**
* Put the live verdict on screen.
*
* Three outcomes, and only one of them is an entrance: the banner is gone (the
* prerendered one is now false, and saying nothing beats saying something wrong),
* the banner changed state (it arrives, and a critical one announces itself), or
* the same banner is simply a few hours staler than the build (its text is swapped
* with no animation, because nothing changed for the reader to notice).
*/
function renderBanner(mint: MintDetail, f: Formatters): void {
const head = document.querySelector<HTMLElement>('.mint-head');
if (!head) return;
const existing = document.querySelector<HTMLElement>('[data-mint-banner]');
// Same decision the build made, in the language the page is in: a fresher probe
// must never swap a Dutch banner for an English one.
const next: MintWarning | undefined = getMintWarnings(mint, warningOptions(f))[0];
if (!next) {
if (existing) remove(existing);
return;
}
if (existing && existing.dataset['warningKind'] === next.kind) {
// Same story, possibly a different day count. Swap the words, move nothing.
const quiet = document.createElement('div');
quiet.innerHTML = bannerHtml(next);
const paragraph = quiet.querySelector('p');
const target = existing.querySelector('p');
if (paragraph && target) target.replaceChildren(...paragraph.childNodes);
return;
}
const holder = document.createElement('div');
holder.innerHTML = bannerHtml(next, { live: true });
const banner = holder.firstElementChild as HTMLElement | null;
if (!banner) return;
if (!prefersReducedMotion()) banner.classList.add('arriving');
if (existing) existing.replaceWith(banner);
else head.after(banner);
}
/** Keep the two places that repeat the banner's facts in step with it. */
function renderCapabilities(mint: MintDetail, f: Formatters): void {
const caps = readCapabilities(mint.info?.nuts);
const limits = document.querySelector<HTMLElement>('[data-live-limits]');
if (limits) limits.innerHTML = limitsHtml(mint.info?.nuts, caps, f);
// Absent when the mint has never had a NUT list cached, which the panel says in
// words instead. Nothing to bring up to date in that case.
const nuts = document.querySelector<HTMLElement>('[data-live-nuts]');
if (nuts) nuts.innerHTML = nutRowsHtml(mint.nuts, caps, f);
}
const setup = () => {
const marker = document.querySelector<HTMLElement>('[data-mint-live]');
const host = marker?.dataset['mintLive'];
if (!host) return;
/*
* Read inside setup, which `onReady` runs again after every view transition, so
* clicking from an English mint page to a Dutch one gets a Dutch translator here
* rather than the one this module captured on first load.
*/
const t = useI18n();
const f = formatters(t);
void (async () => {
try {
const res = await fetch(`${apiBase}/api/mints/${encodeURIComponent(host)}`);
if (!res.ok) return;
const mint = (await res.json()) as MintDetail;
const chip = document.querySelector<HTMLElement>('[data-live-status]');
const label = document.querySelector<HTMLElement>('[data-live-status-label]');
if (chip && label) {
chip.classList.remove('online', 'offline', 'degraded', 'unknown');
chip.classList.add(mint.status);
label.textContent = statusLabel(mint.status, t);
// One ring off the dot, once, the moment the status is confirmed live.
const dot = chip.querySelector<HTMLElement>('.status-dot');
if (dot && !prefersReducedMotion()) {
dot.classList.add('ping');
dot.addEventListener('animationend', () => dot.classList.remove('ping'), { once: true });
}
}
renderBanner(mint, f);
renderCapabilities(mint, f);
const checked = document.querySelector<HTMLElement>('[data-live-checked]');
if (checked) {
checked.textContent =
mint.status !== 'offline' ? t('mint.checked', { when: f.relative(mint.last_probe) })
: mint.last_online ? t('mint.lastSeen', { when: f.relative(mint.last_online) })
: t('mint.neverReached');
}
} catch {
// API unreachable: the build-time state stays on screen rather than blanking.
}
})();
};
onReady(setup);
</script>
+142
View File
@@ -0,0 +1,142 @@
---
/**
* The "Mint QR" dialog: a scannable code that adds this mint to a wallet.
*
* The old site had this as a toggle that unfolded a QR underneath a copy button
* (`components/cards/QRCard.tsx`). Here it is a modal instead, because the mint page
* header is already dense and the code is only wanted for the few seconds it takes to
* point a phone at it.
*
* The code is encoded at build time, so the dialog is complete markup: it opens
* instantly and needs JavaScript only to be opened at all. Any `[data-qr-open]`
* button on the page is a trigger, the same way `[data-write-open]` opens the review
* dialog.
*/
import { qrSvg } from '../lib/qr';
import { localePath, useI18n } from '../i18n';
import { pageLocale } from '../i18n/paths';
interface Props {
/** The mint URL, exactly as a wallet should receive it. */
url: string;
/** Shown in the heading. */
name: string;
}
const { url, name } = Astro.props;
const svg = qrSvg(url);
const { locale } = pageLocale(Astro);
const t = useI18n(locale);
---
<dialog class="modal qr-dialog" data-qr-dialog aria-labelledby="qr-title">
<div class="modal-body qr-body">
<div class="modal-head qr-head">
{/* The mint's name is data, dropped into a translated sentence. */}
<h2 id="qr-title">{t('mint.qr.title', { name })}</h2>
<button type="button" class="copy-btn" data-qr-close aria-label={t('common.close')}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
<p class="qr-note">{t('mint.qr.note')}</p>
<div class="qr-frame">
<Fragment set:html={svg} />
</div>
<div class="qr-url">
<span class="mono">{url}</span>
<button class="copy-btn" data-copy={url} aria-label={t('mint.copyUrl')} type="button">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<rect x="9" y="9" width="12" height="12" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" />
</svg>
</button>
</div>
<p class="qr-warn">
{t('mint.qr.warn')} <a href={localePath('/disclaimer', locale)}>{t('mint.qr.whatMeans')}</a>
<span aria-hidden="true"> · </span>
<a href={localePath('/wallets', locale)}>{t('mint.qr.wallets')}</a>
</p>
<div class="qr-actions">
<button type="button" class="btn" data-copy={url}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<rect x="9" y="9" width="12" height="12" rx="2" /><path d="M5 15V5a2 2 0 0 1 2-2h10" />
</svg>
{t('mint.copyUrl')}
</button>
<button type="button" class="btn primary" data-qr-close>{t('common.done')}</button>
</div>
</div>
</dialog>
<style>
/* The shell, the entrance and the head are the site's one modal (.modal, in
global.css). Only the width and what is inside it are this dialog's own. */
.qr-dialog { max-width: 400px; }
.qr-note { font-size: 13px; color: var(--muted); line-height: 1.55; }
/*
* The white plate is the quiet zone a camera needs, so it stays white in every
* theme and never inherits the page's paper. The code itself is square and sized by
* the plate, which is why the SVG is width/height 100%.
*/
.qr-frame {
background: #FFFFFF; border-radius: 12px; padding: 12px;
align-self: center; width: 236px; max-width: 100%;
aspect-ratio: 1; box-sizing: border-box;
}
.qr-frame :global(svg) { display: block; }
.qr-url {
display: flex; align-items: center; gap: 8px;
background: var(--card2); border: 1px solid var(--line-soft);
border-radius: 9px; padding: 9px 11px;
font-size: 12.5px; color: var(--muted); overflow-wrap: anywhere;
}
.qr-url .mono { flex: 1; font-family: var(--mono); }
.qr-url .copy-btn { flex-shrink: 0; }
.qr-warn { font-size: 12px; color: var(--faint); line-height: 1.55; }
/* Neither link breaks across lines: half a link on each row reads as two links. */
.qr-warn a { color: var(--muted); border-bottom: 1px dotted var(--faint); white-space: nowrap; }
.qr-warn a:hover { color: var(--amber); }
.qr-actions { display: flex; gap: 10px; justify-content: flex-end; flex-wrap: wrap; }
</style>
<script>
import { wireCopyButtons } from '../lib/client';
import { closeModal, openModal, wireModal } from '../lib/modal';
import { onReady } from '../scripts/reveal';
onReady(() => {
const dialog = document.querySelector<HTMLDialogElement>('[data-qr-dialog]');
if (!dialog) return;
// Backdrop click, Escape and the page lock behind it all come from lib/modal.ts,
// which every dialog on the site shares.
wireModal(dialog);
for (const button of document.querySelectorAll('[data-qr-close]')) {
button.addEventListener('click', () => closeModal(dialog));
}
for (const trigger of document.querySelectorAll('[data-qr-open]')) {
trigger.addEventListener('click', () => {
openModal(dialog);
// Escape already closes it; give the keyboard somewhere to land first.
dialog.querySelector<HTMLButtonElement>('[data-qr-close]')?.focus();
});
}
// The two copy buttons inside the dialog are wired here rather than by the page,
// so the dialog works wherever it is dropped in.
wireCopyButtons(dialog);
});
</script>
+26
View File
@@ -0,0 +1,26 @@
---
/** The pixel moai. Crisp edges, ~22px, never animated (DESIGN.md). */
interface Props {
size?: number;
}
const { size = 22 } = Astro.props;
const height = Math.round((size / 11) * 13);
---
<svg
width={size}
height={height}
viewBox="0 0 11 13"
shape-rendering="crispEdges"
aria-hidden="true"
focusable="false"
>
<rect x="2" y="0" width="7" height="1" fill="#C9BFA4" />
<rect x="1" y="1" width="9" height="2" fill="#C9BFA4" />
<rect x="1" y="3" width="2" height="7" fill="#A89B7C" />
<rect x="3" y="3" width="6" height="7" fill="#C9BFA4" />
<rect x="3" y="4" width="2" height="1" fill="#5C5340" />
<rect x="7" y="4" width="2" height="1" fill="#5C5340" />
<rect x="5" y="5" width="1" height="3" fill="#A89B7C" />
<rect x="2" y="10" width="8" height="2" fill="#A89B7C" />
</svg>
+175
View File
@@ -0,0 +1,175 @@
---
/**
* Island: the pulse strip under the hero.
*
* Prerendered with build-time values so it is never blank and never shifts layout,
* then refreshed in one round trip from /api/stats (+ /api/health for the relay dot).
*/
import type { Health, Stats } from '@cashumints/shared';
import { useI18n } from '../i18n';
import { formatters } from '../i18n/format';
import { pageLocale } from '../i18n/paths';
interface Props {
stats: Stats;
health: Health | null;
}
const { stats, health } = Astro.props;
const { locale } = pageLocale(Astro);
const t = useI18n(locale);
const f = formatters(t);
const indexerOk = health?.status === 'ok';
---
<div class="pulse" data-pulse data-reveal>
<div class="pulse-inner">
<div class="pulse-item">
<span class="pulse-dot" data-pulse-dot></span>
<b data-pulse="mints_total">{f.number(stats.mints_total)}</b> {t('pulse.mintsIndexed')}
</div>
<div class="pulse-item">
<b data-pulse="reviews_total">{f.number(stats.reviews_total)}</b> {t('pulse.reviewsOnNostr')}
</div>
<div class="pulse-item">
{t('pulse.lastReview')} <b data-pulse="last_review">{f.relative(stats.last_review_at)}</b>
</div>
<div class="pulse-item">
<b data-pulse="mints_online">{f.number(stats.mints_online)}</b> {t('pulse.mintsOnline')}
</div>
<div class="pulse-right">
{t('pulse.indexer')}
<span data-pulse-status>{`● ${indexerOk ? t('pulse.connected') : t('pulse.stale')}`}</span>
</div>
</div>
</div>
<style>
.pulse {
border-top: 1px solid var(--line-soft);
border-bottom: 1px solid var(--line-soft);
background: var(--card);
}
.pulse-inner {
max-width: var(--page-max);
margin: 0 auto;
padding: 13px var(--page-pad);
display: flex;
align-items: center;
gap: 26px;
flex-wrap: wrap;
font-family: var(--mono);
font-size: 12.5px;
color: var(--faint);
}
.pulse-item { display: flex; align-items: center; gap: 8px; }
/*
* Tabular figures: these numbers count up and are replaced live, and proportional
* digits would jitter the whole strip's width while they did it.
*/
.pulse-item b {
color: var(--text); font-weight: 500;
font-variant-numeric: tabular-nums;
display: inline-block;
}
.pulse-dot {
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
background: var(--mint);
box-shadow: 0 0 8px rgba(95, 214, 143, .7);
}
/* The one allowed flourish (DESIGN.md), gated on reduced motion below. */
.pulse-dot { animation: pulse-glow 2.4s ease-in-out infinite; }
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 6px rgba(95, 214, 143, .5); }
50% { box-shadow: 0 0 11px rgba(95, 214, 143, .9); }
}
.pulse-dot.stale { background: var(--amber); box-shadow: 0 0 8px rgba(240, 169, 59, .7); animation: none; }
.pulse-right { margin-left: auto; color: var(--faint); }
.pulse-right span { color: var(--mint); }
.pulse-right span.stale { color: var(--amber); }
@media (max-width: 720px) {
.pulse-right { display: none; }
.pulse-inner { gap: 16px; font-size: 12px; }
}
</style>
<script>
import { apiBase } from '../lib/client';
import { useI18n } from '../i18n/client';
import { formatters } from '../i18n/format';
import { countUp, onReady, swapText, whenRevealed } from '../scripts/reveal';
import type { Health, Stats } from '@cashumints/shared';
const setup = () => {
const root = document.querySelector<HTMLElement>('[data-pulse]');
if (!root) return;
// Re-read on every arrival: the strip is on the home page in three languages.
const t = useI18n();
const f = formatters(t);
/*
* A value that changed under the reader dips and rises 4px, so a number that
* moved is visibly a different number and not a redraw. The first render is the
* count-up below, not this.
*/
const set = (key: string, value: string) => {
const el = root.querySelector<HTMLElement>(`[data-pulse="${key}"]`);
if (el) swapText(el, value);
};
/*
* The three counts run up from zero when the strip arrives. Their targets are
* read back out of the prerendered markup, which is the build-time truth and
* what stays on screen if the API cannot be reached.
*/
whenRevealed(root, () => {
for (const el of root.querySelectorAll<HTMLElement>('b[data-pulse]')) {
const text = el.textContent?.trim() ?? '';
/*
* "last review" holds a duration, not a count, and only real numbers count up.
* The separator is the locale's: 1,247 in English and 1.247 in Spanish and
* Dutch, so both are stripped before the value is read back.
*/
if (!/^[\d.,\u202f\u00a0]+$/.test(text)) continue;
const target = Number(text.replace(/[.,\u202f\u00a0]/g, ''));
if (Number.isFinite(target) && target > 0) countUp(el, target);
}
});
const refresh = async () => {
try {
// Two cheap endpoints, fired together: one round trip's worth of latency.
const [statsRes, healthRes] = await Promise.all([
fetch(`${apiBase}/api/stats`),
fetch(`${apiBase}/api/health`),
]);
if (!statsRes.ok) return;
const stats = (await statsRes.json()) as Stats;
set('mints_total', f.number(stats.mints_total));
set('reviews_total', f.number(stats.reviews_total));
set('mints_online', f.number(stats.mints_online));
set('last_review', f.relative(stats.last_review_at));
// /api/health answers 503 when the indexer is stale, which is still a body.
const health = (await healthRes.json().catch(() => null)) as Health | null;
const stale = health?.status !== 'ok';
const dot = root.querySelector('[data-pulse-dot]');
const label = root.querySelector('[data-pulse-status]');
dot?.classList.toggle('stale', stale);
label?.classList.toggle('stale', stale);
if (label) label.textContent = `● ${stale ? t('pulse.stale') : t('pulse.connected')}`;
} catch {
// API unreachable at runtime: the prerendered numbers stay on screen.
// Never blank a section that rendered at build time (FRONTEND.md).
}
};
void refresh();
};
onReady(setup);
</script>
+991
View File
@@ -0,0 +1,991 @@
---
/**
* Island: the reviews panel.
*
* Prerendered with the counts the API already knows, so the panel occupies its final
* space and shows real numbers with JavaScript off. Review bodies live on Nostr, so
* the list itself hydrates from relays on load.
*/
import type { MintDetail } from '@cashumints/shared';
import { renderBones } from 'boneyard-js';
import reviewsBones from '../bones/reviews-panel.bones.json';
import { BONE_COLOR } from '../lib/skeleton';
import { useI18n } from '../i18n';
import { formatters } from '../i18n/format';
import { pageLocale } from '../i18n/paths';
interface Props {
mint: MintDetail;
}
const { mint } = Astro.props;
const { locale } = pageLocale(Astro);
const t = useI18n(locale);
const f = formatters(t);
const dist = mint.rating_distribution;
const critical = dist['1'] + dist['2'];
const fiveStar = dist['5'];
/**
* Bones are drawn into the prerendered HTML, not by the island.
*
* `renderBones` is a pure string builder, so it runs here at build time just as well
* as in the browser. Doing it here means the skeleton is on screen at first paint
* instead of after the island's chunk has downloaded, which on a slow connection is
* the difference between a skeleton and a second of loading text followed by a jump.
*
* One block per captured breakpoint, picked by media query in global.css, because
* bone heights differ per width and the server cannot know the viewport.
*/
const boneBlocks = Object.entries(reviewsBones.breakpoints)
.map(([width, skeleton]) => ({ width: Number(width), html: renderBones(skeleton, BONE_COLOR, false) }))
.sort((a, b) => a.width - b.width);
// Bones stand in for reviews the API has already counted. With none counted there is
// nothing to wait for, and the empty state below is the truthful thing to prerender.
const showBoneBlocks = mint.review_count > 0 && boneBlocks.length > 0;
/**
* Without JavaScript the relays are never queried, so the bones would sit there for
* good. Drop them and say what is actually going on.
*
* Emitted as raw HTML on purpose: Astro hoists every <style> it parses out of the
* template and into the page's stylesheet, which would hide the bones for everyone.
*/
const noscriptFallback =
`<noscript><style>[data-review-items] .by-bones{display:none}</style>` +
`<p class="rev-status">${escapeText(t('reviews.noscript'))}</p></noscript>`;
/** This block goes in as raw HTML, so the one translated string in it is escaped. */
function escapeText(value: string): string {
return value
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
---
{/*
The island's root is `data-reviews-panel`, not `data-reviews`: a mint card already
carries that one, as its review count, and this island now looks for its root on
every page the router brings in, not only on a mint page.
*/}
<section
class="panel"
aria-labelledby="reviews-title"
data-reveal
data-reviews-panel
data-mint-url={mint.url}
data-mint-pubkey={mint.pubkey ?? ''}
data-mint-name={mint.name ?? mint.host}
>
<div class="panel-head">
<h2 class="panel-title" id="reviews-title">{t('reviews.title')}</h2>
{/* A protocol name, not a label: NIP-87 is NIP-87 in every language. */}
<span class="nip-badge">NIP-87</span>
<div class="filters" role="group" aria-label={t('reviews.filter.group')}>
<button class="filter" type="button" data-filter="all" aria-pressed="true">
{t('reviews.filter.all')} · <span data-count="all">{f.number(mint.review_count)}</span>
</button>
<button class="filter" type="button" data-filter="5" aria-pressed="false">
{t('reviews.filter.five')} · <span data-count="5">{f.number(fiveStar)}</span>
</button>
<button class="filter" type="button" data-filter="critical" aria-pressed="false">
{t('reviews.filter.critical')} · <span data-count="critical">{f.number(critical)}</span>
</button>
<button class="filter" type="button" data-filter="recent" aria-pressed="false">
{t('reviews.filter.recent')}
</button>
</div>
</div>
{/*
Filled by the island with one line summarising every review that carried a rating
and no words. Empty (and invisible) until the relays answer, and on a mint where
every review has a body it stays empty.
*/}
<div data-rating-summary></div>
{/*
Only the panel body is skeletoned: the header, badge, filter chips and their
counts above are prerendered and stay put. `data-boneyard` marks the capture
target for `pnpm bones`, which snapshots this element's single child.
*/}
<div data-review-list data-review-count={mint.review_count}>
<div data-boneyard="reviews-panel" data-boneyard-config='{"excludeSelectors":[".sr-only"]}'>
<div data-review-items>
{
showBoneBlocks ? (
<Fragment>
<div class="by-bones by-shimmer" aria-hidden="true">
{boneBlocks.map((block) => (
<div class="by-bp" data-by-bp={block.width} set:html={block.html} />
))}
</div>
<p class="sr-only" role="status">
{t('reviews.loading', { n: mint.review_count })}
</p>
<Fragment set:html={noscriptFallback} />
</Fragment>
) : (
<p class="rev-status" data-review-status>
{
mint.review_count === 0
? t('reviews.empty')
: `${t('reviews.loading', { n: mint.review_count })}…`
}
</p>
)
}
</div>
</div>
</div>
<div class="load-row" data-pager hidden>
<div class="pager" role="group" aria-label={t('common.reviewPages')}></div>
</div>
</section>
<!--
The write review dialog.
A modal in the site's one modal shell (.modal, see global.css): centred, backdrop
blurred, page locked behind it, focus trapped by the browser because it is a real
<dialog> opened with showModal().
Everything except the identity row is prerendered. The identity row cannot be: who
is signing is only knowable in the browser, and asking a NIP-07 extension for a
pubkey is a question worth asking once the reader has opened the dialog, not on
every page load.
-->
<dialog class="modal write-dialog" data-write-dialog aria-labelledby="write-title">
<form method="dialog" class="modal-body write-form" data-write-form novalidate>
<div class="modal-head">
{/* The mint's name is data, dropped into a translated sentence. */}
<h2 id="write-title">{t('reviews.dialog.title', { name: mint.name ?? mint.host })}</h2>
<button type="button" class="copy-btn" data-write-close aria-label={t('common.close')}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
<p class="modal-note">{t('reviews.dialog.note')}</p>
<div class="write-id" data-write-identity></div>
<fieldset class="star-picker" data-star-picker>
<legend class="sr-only">{t('reviews.dialog.ratingLegend')}</legend>
{
[1, 2, 3, 4, 5].map((value) => (
<label class="star-option">
<input type="radio" name="rating" value={value} required />
<span aria-hidden="true">★</span>
<span class="sr-only">{t('reviews.dialog.starsSr', { n: value })}</span>
</label>
))
}
<span class="star-label" data-star-label aria-live="polite">
{t('reviews.dialog.chooseRating')}
</span>
</fieldset>
<label class="write-label" for="review-body">{t('reviews.dialog.bodyLabel')}</label>
<div class="write-box">
<textarea
id="review-body"
name="content"
rows="5"
aria-describedby="review-count"
placeholder={t('reviews.dialog.bodyPlaceholder')}></textarea>
<span class="write-count" id="review-count" data-char-count>
{t('reviews.dialog.charCount', { n: 0, max: 2000 })}
</span>
</div>
<p class="write-status" data-write-status hidden role="status"></p>
<p class="write-error" data-write-error hidden role="alert"></p>
<div class="write-actions">
<span class="write-why" data-write-why></span>
<button type="button" class="btn" data-write-close>{t('common.cancel')}</button>
<button type="submit" class="btn primary" data-write-submit disabled>
<span class="write-spinner" aria-hidden="true"></span>
<span data-write-submit-label>{t('reviews.dialog.submit')}</span>
</button>
</div>
</form>
</dialog>
<style>
/* The write dialog's own styles live in global.css beside the other modals: the
identity row and the status line are injected by the island, and Astro's scoped
selectors do not reach markup added through innerHTML. */
.load-row { padding: 16px 20px; display: flex; justify-content: center; border-top: 1px solid var(--line-soft); }
.pager { display: flex; gap: 6px; align-items: center; font-size: 13.5px; flex-wrap: wrap; justify-content: center; }
</style>
<script>
import { escapeHtml, wireCopyableIds } from '../lib/client';
import { useI18n } from '../i18n/client';
import { formatters } from '../i18n/format';
import { enterStagger, onReady, prefersReducedMotion, scrollBehavior } from '../scripts/reveal';
import { getExtension, loadProfiles, loadReviews, publishReview, safeNpub, type LoadedReview } from '../lib/reviews-client';
import { activeSigner, onSessionChange, onSignerStatus, readSession, signerErrorMessage } from '../lib/nostr-signer';
// The identity row is the same identity the header shows, drawn by the same helpers.
import { avatarMarkup, identityLines, methodChipHtml, openLogin, resolveProfile, toNpub } from '../lib/auth-ui';
import { closeModal, openModal, wireModal } from '../lib/modal';
import type { Profile } from '@cashumints/shared';
// The card itself lives in one place: /reviews renders the identical markup from
// the same functions.
import { applyProfiles, isWritten, reviewHtml, summaryHtml } from '../lib/review-cards';
import { adoptBones, armBuildSnapshot, clearBones, isBuildMode, showBones, swapBones } from '../lib/skeleton';
import reviewsBones from '../bones/reviews-panel.bones.json';
const setup = () => {
wireCopyableIds();
/*
* Built here, not at module scope. `onReady` runs this again after every view
* transition, so a reader who clicks from an English mint page to a Dutch one gets a
* panel whose filters, empty states and dialog are all in the language they just
* asked for, rather than the one the module happened to load in.
*/
const t = useI18n();
const f = formatters(t);
const root = document.querySelector<HTMLElement>('[data-reviews-panel]');
if (root) {
const mintUrl = root.dataset['mintUrl'] ?? '';
const mintPubkey = root.dataset['mintPubkey'] || null;
const listWrap = root.querySelector<HTMLElement>('[data-review-list]')!;
const list = root.querySelector<HTMLElement>('[data-review-items]')!;
const summarySlot = root.querySelector<HTMLElement>('[data-rating-summary]')!;
const pagerWrap = root.querySelector<HTMLElement>('[data-pager]')!;
const pager = pagerWrap.querySelector<HTMLElement>('.pager')!;
const PAGE_SIZE = 10;
/** Relays get this long to answer before the bones give way to the error state. */
const RELAY_TIMEOUT_MS = 8000;
/** How many reviews the API counted at build time. Drives whether bones are worth showing. */
const expected = Number(listWrap.dataset['reviewCount'] ?? '0');
let all: LoadedReview[] = [];
let filter: 'all' | '5' | 'critical' | 'recent' = 'all';
let page = 1;
/** True while bones are on screen, so the first real render cross-fades over them. */
let bonesUp = false;
/**
* A scheduled repaint, while the outgoing list is fading.
*
* At most one is ever in flight: a filter changed twice in 120ms does not queue a
* second fade, it lands on whichever filter is set when the timer runs, because
* the paint reads the current state rather than a captured one.
*/
let pendingPaint = 0;
/** Set for one paint after publishing, to mark the new card as it goes in. */
let markNewest = false;
/**
* Reviews published in this session, shown at the top until relays echo them back.
*
* Keyed by event id, holding how many relays took it: a review that only two of
* four relays accepted says so on its card, because that is a thing the author
* may want to act on.
*/
const optimistic = new Map<string, { accepted: number; total: number }>();
/* ---------- rendering ---------- */
function filtered(): LoadedReview[] {
return filter === '5' ? all.filter((r) => r.rating === 5)
: filter === 'critical' ? all.filter((r) => r.rating !== null && r.rating <= 2)
: filter === 'recent' ? all.filter((r) => r.created_at > Date.now() / 1000 - 90 * 86400)
: all;
}
/* ---------- rating only summary ---------- */
/**
* One row for every rating with no comment, collapsed by default. The filter
* counts above still count these; this only decides how much room they take.
*/
function renderSummary(): void {
const rows = filtered().filter((review) => !isWritten(review));
const open = summarySlot.querySelector('details')?.open ?? false;
summarySlot.innerHTML = summaryHtml(rows, f, open);
}
function renderInto(target: HTMLElement): void {
const rows = filtered().filter(isWritten);
if (rows.length === 0) {
// With ratings but no words, "no reviews yet" would be false: the summary row
// above is showing them. Say what is actually missing instead.
const message =
all.length === 0 ? t('reviews.empty')
: filtered().length > 0 ? t('reviews.emptyWritten')
: t('reviews.emptyFilter');
target.innerHTML = `<p class="rev-status">${escapeHtml(message)}</p>`;
pagerWrap.hidden = true;
return;
}
const pages = Math.ceil(rows.length / PAGE_SIZE);
page = Math.min(page, pages);
const slice = rows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
target.innerHTML = slice
.map((review) =>
reviewHtml(review, f, {
propagating: optimistic.has(review.id),
publishedTo: optimistic.get(review.id) ?? null,
}),
)
.join('');
pagerWrap.hidden = pages <= 1;
if (pages > 1) {
const button = (label: string, target: number, current: boolean, aria?: string) =>
`<button class="pg${current ? ' on' : ''}" type="button" data-page="${target}"${
aria ? ` aria-label="${aria}"` : ''
}${current ? ' aria-current="true"' : ''}>${label}</button>`;
// The chevrons are glyphs, not words. Only their labels are translated.
const parts = [button('‹', Math.max(1, page - 1), false, t('common.previousPage'))];
for (let p = 1; p <= pages; p++) parts.push(button(String(p), p, p === page));
parts.push(button('›', Math.min(pages, page + 1), false, t('common.nextPage')));
pager.innerHTML = parts.join('');
}
}
/**
* Draw the list. The first call after the relays answer hands over from the
* bones; every later one redraws in place, because by then the reviews are
* already here and there is nothing to wait for.
*/
function paint(): void {
renderSummary();
if (bonesUp) {
bonesUp = false;
// swapBones staggers the rows in as the bones fade off them.
swapBones(list, renderInto);
} else {
renderInto(list);
enterStagger([...list.children] as HTMLElement[]);
}
if (markNewest) {
markNewest = false;
// Published in this session: a soft amber edge for a second and a half, then
// it is an ordinary review like every other one.
list.querySelector<HTMLElement>('.review')?.classList.add('is-new');
}
}
/**
* Repaint, optionally letting the outgoing list fade out first.
*
* `animate` is for a change the reader made (a filter, a page): the old rows fade
* out in 120ms and the new ones rise in staggered behind them, under 350ms in
* total. Only one repaint is ever scheduled, and it draws whatever the state is
* when it runs, so hammering the filters cannot stack or queue animations.
*/
function render(animate = false): void {
if (pendingPaint) return;
if (!animate || bonesUp || prefersReducedMotion()) {
paint();
return;
}
list.classList.add('leaving');
pendingPaint = window.setTimeout(() => {
pendingPaint = 0;
list.classList.remove('leaving');
paint();
}, 120);
}
const patchProfiles = (): void =>
applyProfiles(root!, f, (pubkey) => all.find((r) => r.pubkey === pubkey));
function updateCounts(): void {
const set = (key: string, value: number) => {
const el = root!.querySelector(`[data-count="${key}"]`);
if (el) el.textContent = f.number(value);
};
// Counts cover every review, written or not, so the filters stay honest about
// how many ratings a mint has even though most of them render as one line.
set('all', all.length);
set('5', all.filter((r) => r.rating === 5).length);
set('critical', all.filter((r) => r.rating !== null && r.rating <= 2).length);
}
/* ---------- loading ---------- */
/** Bones never spin forever: past this the reader gets an error and a way out. */
function withTimeout<T>(work: Promise<T>, ms: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = window.setTimeout(() => reject(new Error('relay-timeout')), ms);
work.then(
(value) => {
window.clearTimeout(timer);
resolve(value);
},
(error: unknown) => {
window.clearTimeout(timer);
reject(error instanceof Error ? error : new Error(String(error)));
},
);
});
}
function showRelayError(): void {
bonesUp = false;
clearBones(list);
list.innerHTML =
`<p class="rev-status error" role="status">${escapeHtml(t('reviews.relayError'))} ` +
`<button class="rev-retry" type="button" data-review-retry>${escapeHtml(t('common.retry'))}</button></p>`;
pagerWrap.hidden = true;
}
async function load(): Promise<void> {
// Bones stand in for reviews the API has already counted. With none counted
// the prerendered "no reviews yet" line is the truthful thing to leave up.
// The bones are already in the prerendered HTML, so first load only has to take
// them over. After the error state has replaced them a retry has to draw them
// again, and that path renders from the same bone data.
if (expected > 0) {
const bones =
adoptBones(list, 3) ??
showBones(list, reviewsBones, {
groups: 3,
label: t('reviews.loading', { n: expected }),
});
bonesUp = bones !== null;
}
let loaded: LoadedReview[];
try {
loaded = await withTimeout(loadReviews(mintUrl, mintPubkey), RELAY_TIMEOUT_MS);
} catch {
showRelayError();
return;
}
// The relay pool swallows per relay failures and resolves empty, so nothing
// coming back for a mint the API counted reviews for means the relays did not
// answer, not that the reviews are gone. Saying "no reviews yet" there would
// be a lie the reader cannot check.
if (loaded.length === 0 && expected > 0) {
showRelayError();
return;
}
all = loaded;
// Rendered with npub fallbacks first: the text is what people came for, and
// names arrive into a line that is already the right height.
render();
updateCounts();
try {
const profiles = await loadProfiles(all.map((r) => r.pubkey));
for (const review of all) review.profile = profiles.get(review.pubkey) ?? null;
} catch {
// Names stay as short npubs. Nothing else changes.
} finally {
patchProfiles();
}
}
root.addEventListener('click', (event) => {
const target = event.target as HTMLElement;
if (target.closest('[data-review-retry]')) {
void load();
return;
}
const filterButton = target.closest<HTMLButtonElement>('[data-filter]');
if (filterButton) {
filter = filterButton.dataset['filter'] as typeof filter;
page = 1;
for (const b of root!.querySelectorAll('[data-filter]')) {
b.setAttribute('aria-pressed', String(b === filterButton));
}
render(true);
return;
}
const pageButton = target.closest<HTMLButtonElement>('[data-page]');
if (pageButton) {
page = Number(pageButton.dataset['page']);
render(true);
root!.scrollIntoView({ behavior: scrollBehavior(), block: 'start' });
}
});
if (isBuildMode()) {
// `pnpm bones` is measuring this panel. Relays are not involved: render the
// fixture reviews through the same function the real ones go through, so the
// bones Boneyard captures are the shape of a real card and stay that way when
// the card's markup changes.
// Imported dynamically so the fixture text never reaches a real visitor.
void armBuildSnapshot()
.then(() => import('../lib/skeleton-fixtures'))
.then(({ FIXTURE_REVIEWS }) => {
all = FIXTURE_REVIEWS;
renderInto(list);
});
} else {
void load();
}
/* ---------- write a review ---------- */
const dialog = document.querySelector<HTMLDialogElement>('[data-write-dialog]');
const form = dialog?.querySelector<HTMLFormElement>('[data-write-form]');
const errorBox = dialog?.querySelector<HTMLElement>('[data-write-error]');
const statusBox = dialog?.querySelector<HTMLElement>('[data-write-status]');
const submit = dialog?.querySelector<HTMLButtonElement>('[data-write-submit]');
const submitLabel = dialog?.querySelector<HTMLElement>('[data-write-submit-label]');
const textarea = dialog?.querySelector<HTMLTextAreaElement>('textarea');
const charCount = dialog?.querySelector<HTMLElement>('[data-char-count]');
const starLabel = dialog?.querySelector<HTMLElement>('[data-star-label]');
const identityRow = dialog?.querySelector<HTMLElement>('[data-write-identity]');
const whyDisabled = dialog?.querySelector<HTMLElement>('[data-write-why]');
/** Reviews are capped at this length, and the counter turns red past it. */
const MAX_CHARS = 2000;
/** Below this a review says nothing anyone can use. */
const MIN_CHARS = 10;
// One phrase per star, in the reader's words rather than the site's. Shown as
// "2: had problems" beside the picker, and read out by the live region.
// One phrase per star, from the catalog. Read through `t` rather than held in a
// table here, so the phrases follow the page's language after a view transition.
const starPhrase = (rating: number): string => t(`reviews.dialog.stars.${rating}`);
/** True once a publish is in flight, so nothing else runs over it. */
let publishing = false;
/** Who is going to sign, resolved when the dialog opens. */
let canSign = false;
function showError(message: string): void {
if (!errorBox) return;
errorBox.textContent = message;
errorBox.hidden = message === '';
}
function showStatus(message: string): void {
if (!statusBox) return;
statusBox.textContent = message;
statusBox.hidden = message === '';
}
/* ---------- draft safety ---------- */
/**
* A written review survives an accidental close.
*
* sessionStorage, keyed by mint: it lasts as long as the tab and no longer, which
* is the life of the intention. Nothing here is secret, but nothing here belongs
* on the machine a week later either.
*/
const DRAFT_KEY = `cashumints:draft:${mintUrl}`;
function saveDraft(): void {
if (!form) return;
const data = new FormData(form);
const rating = String(data.get('rating') ?? '');
const content = String(data.get('content') ?? '');
try {
if (!rating && !content.trim()) sessionStorage.removeItem(DRAFT_KEY);
else sessionStorage.setItem(DRAFT_KEY, JSON.stringify({ rating, content }));
} catch {
// Storage blocked. The draft lives in the open dialog and nowhere else.
}
}
function clearDraft(): void {
try {
sessionStorage.removeItem(DRAFT_KEY);
} catch {
// Nothing stored to begin with.
}
}
function restoreDraft(): void {
if (!form) return;
let draft: { rating?: string; content?: string } | null = null;
try {
const raw = sessionStorage.getItem(DRAFT_KEY);
draft = raw ? (JSON.parse(raw) as { rating?: string; content?: string }) : null;
} catch {
draft = null;
}
if (!draft) return;
if (draft.rating) {
const star = form.querySelector<HTMLInputElement>(`input[name="rating"][value="${draft.rating}"]`);
if (star) star.checked = true;
}
if (draft.content && textarea) textarea.value = draft.content;
syncStarLabel();
syncCount();
}
/* ---------- the form's own state ---------- */
function chosenRating(): number {
return Number(new FormData(form ?? undefined).get('rating') ?? 0);
}
function syncStarLabel(): void {
if (!starLabel) return;
const rating = chosenRating();
starLabel.textContent = rating
// The rating is a bare numeral here, so it is passed as a string: "3: good",
// never "3,0: good".
? t('reviews.dialog.starLabel', { n: String(rating), phrase: starPhrase(rating) })
: t('reviews.dialog.chooseRating');
}
function syncCount(): void {
const length = textarea?.value.length ?? 0;
if (charCount) {
charCount.textContent = t('reviews.dialog.charCount', { n: length, max: MAX_CHARS });
charCount.classList.toggle('over', length > MAX_CHARS);
}
syncSubmit();
}
/**
* Whether "Sign and publish" is available, and if not, why.
*
* The reason is always on screen: a button that is off for an unstated reason is
* a button people click twice and then leave.
*/
function syncSubmit(): void {
if (!submit) return;
const length = textarea?.value.trim().length ?? 0;
const rating = chosenRating();
const over = (textarea?.value.length ?? 0) - MAX_CHARS;
const reason = publishing
? ''
: !canSign ? t('reviews.dialog.why.login')
: !rating ? t('reviews.dialog.why.rating')
: length === 0 ? t('reviews.dialog.why.words')
: length < MIN_CHARS ? t('reviews.dialog.why.more', { n: MIN_CHARS - length })
: over > 0 ? t('reviews.dialog.why.over', { n: over })
: '';
submit.disabled = publishing || reason !== '';
if (whyDisabled) whyDisabled.textContent = publishing ? '' : reason;
}
/** The publish button, mid-flight or back to rest. */
function setBusy(busy: boolean, label = t('reviews.dialog.submit')): void {
publishing = busy;
submit?.classList.toggle('is-busy', busy);
if (submitLabel) submitLabel.textContent = label;
syncSubmit();
}
/* ---------- who is signing ---------- */
/**
* Draw the identity row, and decide whether publishing is possible at all.
*
* Three states, in the order they are likely:
* - logged in: the session's signer, named, with a way to switch
* - no session but a NIP-07 extension: quick-sign, exactly as before logins
* existed, and no session is created by using it
* - neither: the row becomes the way in, and the publish button says so
*
* The extension's pubkey is asked for here, on open, and never on page load: it
* is a question some extensions put to the reader, and a page that asks it for
* every visitor who might one day write a review is a page that nags.
*/
async function renderIdentity(): Promise<void> {
if (!identityRow) return;
const session = readSession();
if (session) {
// A session that names an extension is only as good as the extension: it can
// be uninstalled, or disabled for this site, between one visit and the next.
canSign = session.method !== 'extension' || getExtension() !== null;
syncSubmit();
if (!canSign) {
identityRow.innerHTML =
`<span class="write-id-who"><span class="write-id-lead">${escapeHtml(
t('reviews.dialog.identity.extensionGone'),
)}</span></span>` +
`<button type="button" class="btn" data-login-open>${escapeHtml(
t('login.open'),
)}</button>`;
return;
}
const npub = await toNpub(session.pubkey);
const draw = (profile: Profile | null): void => {
const { primary, secondary, named } = identityLines(profile, npub, t);
// Whichever of the two lines is the npub is the machine-flavoured one, and
// that is the one set in mono (DESIGN.md). The other is a name or a phrase.
identityRow.innerHTML =
avatarMarkup(session.pubkey, profile, npub, 34) +
`<span class="write-id-who">` +
`<span class="write-id-name${named ? '' : ' mono'}">${escapeHtml(primary)}</span>` +
`<span class="write-id-sub${named ? ' mono' : ''}">${escapeHtml(secondary)}</span></span>` +
methodChipHtml(session.method, t) +
`<button type="button" class="write-id-switch" data-login-open>${escapeHtml(
t('reviews.dialog.identity.switch'),
)}</button>`;
};
// Drawn from the npub first, then again if a name turns up: the row is the
// same height either way, so nothing under it moves when the name lands.
draw(null);
const profile = await resolveProfile(session.pubkey);
if (readSession()?.pubkey !== session.pubkey || !profile?.found) return;
draw(profile);
return;
}
if (!getExtension()) {
canSign = false;
syncSubmit();
identityRow.innerHTML =
`<span class="write-id-who"><span class="write-id-lead">${escapeHtml(
t('reviews.dialog.identity.needSigner'),
)}</span></span>` +
`<button type="button" class="btn" data-login-open>${escapeHtml(
t('reviews.dialog.identity.loginToPublish'),
)}</button>`;
return;
}
canSign = true;
syncSubmit();
identityRow.innerHTML =
`<span class="write-id-who"><span class="write-id-name">${escapeHtml(
t('reviews.dialog.identity.extension'),
)}</span>` +
`<span class="write-id-sub">${escapeHtml(
t('reviews.dialog.identity.readingKey'),
)}</span></span>` +
methodChipHtml('extension', t);
let pubkey = '';
try {
pubkey = (await getExtension()?.getPublicKey()) ?? '';
} catch {
// Declined, or the extension is locked. Publishing still works: it will ask
// again at signing time, which is the request that actually matters.
}
if (!/^[0-9a-f]{64}$/i.test(pubkey)) {
const sub = identityRow.querySelector('.write-id-sub');
if (sub) sub.textContent = t('reviews.dialog.identity.willAsk');
return;
}
const npub = await toNpub(pubkey);
const profile = await resolveProfile(pubkey);
const { primary, named } = identityLines(profile, npub, t);
identityRow.innerHTML =
avatarMarkup(pubkey, profile, npub, 34) +
`<span class="write-id-who">` +
`<span class="write-id-name${named ? '' : ' mono'}">${escapeHtml(primary)}</span>` +
`<span class="write-id-sub">${escapeHtml(
t('reviews.dialog.identity.extension'),
)}</span></span>` +
methodChipHtml('extension', t);
}
/* ---------- opening and closing ---------- */
if (dialog) {
wireModal(dialog, {
// A written review is not thrown away on a stray click outside the box.
onBackdrop: () =>
(textarea?.value.trim().length ?? 0) === 0 ||
window.confirm(t('reviews.dialog.confirmClose')),
onClose: () => {
saveDraft();
showStatus('');
setBusy(false);
},
});
// The signer talks back while a signature is in flight: reconnecting, waiting,
// approve-in-another-window. Mirrored into the dialog so the reader is never
// watching a spinner with nothing behind it.
onSignerStatus((update) => {
if (dialog.open && publishing) showStatus(update.message);
});
// Logging in from the identity row redraws it, without closing this dialog.
onSessionChange(() => {
if (dialog.open) void renderIdentity();
});
}
textarea?.addEventListener('input', () => {
syncCount();
saveDraft();
});
form?.addEventListener('change', (event) => {
if ((event.target as HTMLInputElement).name !== 'rating') return;
syncStarLabel();
syncSubmit();
saveDraft();
});
for (const button of document.querySelectorAll('[data-write-close]')) {
button.addEventListener('click', () => closeModal(dialog!));
}
for (const trigger of document.querySelectorAll('[data-write-open]')) {
trigger.addEventListener('click', () => {
if (!dialog) return;
showError('');
showStatus('');
setBusy(false);
restoreDraft();
openModal(dialog);
void renderIdentity();
// Somewhere to land that is not the close button: the rating is the first
// thing to decide, and a checked star keeps its own place in the group.
const checked = form?.querySelector<HTMLInputElement>('input[name="rating"]:checked');
(checked ?? form?.querySelector<HTMLInputElement>('input[name="rating"]'))?.focus();
});
}
/* ---------- publishing ---------- */
form?.addEventListener('submit', async (event) => {
event.preventDefault();
if (!form || !submit || publishing) return;
const rating = chosenRating();
const content = textarea?.value ?? '';
const signer = activeSigner();
if (!signer) {
// No session and no extension. The row already says so; open the way in.
openLogin(submit);
return;
}
showError('');
setBusy(true, t('reviews.dialog.signing'));
showStatus(
signer.method === 'extension'
? t('reviews.dialog.waitingExtension')
: t('reviews.dialog.waitingSigner'),
);
try {
/*
* The author's own kind 0, fetched while the relays are still taking the
* review. Their pubkey is only knowable once the event is signed, so
* `onSigned` starts this at the earliest moment there is: by the time the
* card below is inserted the name is usually already in hand, and the author
* does not watch their own review appear as "Anon" until the next reload.
*
* `refresh` skips the cache deliberately. Anyone who has reviewed before may
* be sitting on an hour-old negative entry for their own key, and they are
* exactly the reader who would notice their name missing.
*/
let profileLookup: Promise<Profile | null> | null = null;
const result = await publishReview({
signer,
mintUrl,
mintPubkey,
rating,
content,
onSigned: (signed) => {
setBusy(true, t('reviews.dialog.publishing'));
profileLookup = loadProfiles([signed.pubkey], { refresh: true })
.then((profiles) => profiles.get(signed.pubkey) ?? null)
.catch(() => {
// Relays unreachable for the lookup. The card keeps "Anon" over its
// npub, which is what every other unresolved card shows.
return null;
});
},
onProgress: (accepted, _settled, total) => {
showStatus(t('reviews.dialog.publishingTo', { accepted, total }));
},
});
// Optimistic insert at the top. The relays will echo it back on next load.
optimistic.set(result.event.id, { accepted: result.accepted, total: result.total });
all.unshift({
id: result.event.id,
pubkey: result.event.pubkey,
npub: safeNpub(result.event.pubkey),
created_at: result.event.created_at,
rating,
content: content.trim(),
mint_url: mintUrl,
profile: null,
});
filter = 'all';
page = 1;
for (const b of root!.querySelectorAll('[data-filter]')) {
b.setAttribute('aria-pressed', String(b.getAttribute('data-filter') === 'all'));
}
markNewest = true;
render();
updateCounts();
// The dialog is about to close, so the confirmation belongs on the button
// that opened it: one amber pulse, on success only.
const trigger = document.querySelector<HTMLElement>('[data-write-open]');
if (trigger && !prefersReducedMotion()) {
trigger.classList.remove('did-it');
void trigger.offsetWidth;
trigger.classList.add('did-it');
trigger.addEventListener('animationend', () => trigger.classList.remove('did-it'), { once: true });
}
// The relays answer the publish before the lookup answers, near enough every
// time, so the card goes up as "Anon" and the author's name cross-fades in a
// moment later, exactly as it does on every other card.
void (profileLookup as Promise<Profile | null> | null)?.then((profile) => {
if (!profile?.found) return;
for (const review of all) {
if (review.pubkey === result.event.pubkey) review.profile = profile;
}
patchProfiles();
});
clearDraft();
form.reset();
syncStarLabel();
syncCount();
setBusy(false);
showStatus('');
closeModal(dialog!);
} catch (err) {
// Back to an editable dialog with everything still in it. A declined
// signature is not an error the reader made, and a relay that would not take
// it is worth another press of the same button.
setBusy(false);
showStatus('');
const message = err instanceof Error ? err.message : String(err);
showError(
message === 'no-relay'
? t('reviews.dialog.error.noRelay')
: signerErrorMessage(err, t),
);
}
});
}
};
onReady(setup);
</script>
+123
View File
@@ -0,0 +1,123 @@
---
/**
* Uptime sparkline for the verdict strip.
*
* One filled band per probe window: high and unbroken is healthy, a notch to the
* floor is an outage. This replaced a row of coloured blocks that were all the same
* height, so they carried no shape and read as a stray progress bar under the numbers.
*
* Response time modulates the height inside the healthy band, which is what gives the
* line something to say when nothing has gone wrong.
*/
import type { ProbeSample } from '@cashumints/shared';
import { useI18n } from '../i18n';
import { formatters } from '../i18n/format';
import { pageLocale } from '../i18n/paths';
interface Props {
probes: ProbeSample[];
/** 0..1 from the API, rendered as the label under the line. */
uptime?: number | null;
}
const { probes, uptime = null } = Astro.props;
const { locale } = pageLocale(Astro);
const f = formatters(useI18n(locale));
/** Cap the sample count so a long window stays legible; keep the most recent. */
const MAX_POINTS = 60;
const shown = probes.slice(-MAX_POINTS);
const okCount = shown.filter((p) => p.ok === 1).length;
const W = 200;
const H = 24;
/** Keeps the stroke inside the box at both extremes. */
const PAD = 2;
const FLOOR = H - PAD;
const latencies = shown.filter((p) => p.ok === 1 && p.latency_ms !== null).map((p) => p.latency_ms!);
const fastest = latencies.length > 0 ? Math.min(...latencies) : 0;
const slowest = latencies.length > 0 ? Math.max(...latencies) : 0;
const span = slowest - fastest;
/**
* A reachable probe sits high, a little lower the slower it answered. An unreachable
* one drops to the floor, which is the only thing on the line worth noticing.
*/
function y(probe: ProbeSample): number {
if (probe.ok !== 1) return FLOOR;
if (span <= 0 || probe.latency_ms === null) return PAD + 2;
const slowness = (probe.latency_ms - fastest) / span;
// Confined to the top third: this is an uptime line, not a latency chart.
return PAD + 2 + slowness * ((H - PAD * 2) * 0.3);
}
const step = shown.length > 1 ? (W - PAD * 2) / (shown.length - 1) : 0;
const points = shown.map((probe, i) => [PAD + i * step, y(probe)] as const);
const line = points.map(([x, py]) => `${x.toFixed(1)},${py.toFixed(1)}`).join(' ');
const area = `M ${PAD},${FLOOR} L ${points
.map(([x, py]) => `${x.toFixed(1)},${py.toFixed(1)}`)
.join(' L ')} L ${(W - PAD).toFixed(1)},${FLOOR} Z`;
const label =
uptime === null
? f.t('mint.uptime.checks', { ok: okCount, total: shown.length })
// The percentage is a whole number, passed as a string: this is a share, not a
// count, and grouping separators have no business in "97%".
: f.t('mint.uptime.percent', { percent: String(Math.round(uptime * 100)) });
const description = f.t('mint.uptime.description', { ok: okCount, total: shown.length });
---
{
shown.length > 1 && (
<div class="spark-wrap">
{/*
Fill and stroke are presentation attributes rather than CSS: Astro's scoped
styles do not reach elements inside an inline <svg>, and a path with no fill
rule falls back to solid black.
*/}
<svg class="spark" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" role="img" aria-label={description}>
<path class="spark-area" d={area} fill="rgba(240, 169, 59, .12)" stroke="none" />
{/*
`pathLength="1"` normalises the line's length, so the dash that draws it in
is one CSS rule rather than a measurement in JavaScript. With the animation
off (no JS, or reduced motion) a dash of length 1 on a path of length 1 is
just a solid line.
*/}
<polyline
class="spark-line"
points={line}
pathLength="1"
fill="none"
stroke="#F0A93B"
stroke-width="1.5"
stroke-opacity=".85"
stroke-linejoin="round"
stroke-linecap="round"
vector-effect="non-scaling-stroke"
/>
</svg>
<span class="spark-label">{label}</span>
</div>
)
}
<style>
.spark-wrap { margin-top: 14px; }
.spark { width: 100%; height: 24px; display: block; }
.spark-label { display: block; margin-top: 8px; font-size: 12px; color: var(--muted); }
/*
* The line draws itself once its verdict cell arrives, left to right, 500ms. The
* undrawn state only exists while the cell is still waiting, so with JavaScript off
* the line is simply there.
*/
.spark-line {
stroke-dasharray: 1;
stroke-dashoffset: 0;
transition: stroke-dashoffset var(--dur-draw) var(--ease-out) calc(var(--reveal-delay, 0ms) + 120ms);
}
.spark-area { transition: opacity var(--dur-enter) var(--ease-out) calc(var(--reveal-delay, 0ms) + 200ms); }
:global([data-reveal]:not(.in)) .spark-line { stroke-dashoffset: 1; }
:global([data-reveal]:not(.in)) .spark-area { opacity: 0; }
</style>
+83
View File
@@ -0,0 +1,83 @@
---
import Moai from './Moai.astro';
import LanguageSwitcher from './LanguageSwitcher.astro';
import { localePath, useI18n } from '../i18n';
import { pageLocale } from '../i18n/paths';
interface Props {
current?: 'mints' | 'reviews' | 'wallets' | 'about';
/** Shows the compact search affordance next to the brand (mint pages, /mints). */
mintCount?: number;
}
const { current, mintCount } = Astro.props;
const { locale } = pageLocale(Astro);
const t = useI18n(locale);
/*
* Route slugs stay English in every language, so the href is the English path run
* through `localePath`. See src/i18n/routing.ts for why that trade was made.
*/
const links = [
{ path: '/mints', label: t('nav.mints'), key: 'mints' },
{ path: '/reviews', label: t('nav.reviews'), key: 'reviews' },
{ path: '/wallets', label: t('nav.wallets'), key: 'wallets' },
{ path: '/about', label: t('nav.about'), key: 'about' },
] as const;
---
<header class="topbar">
<div class="topbar-inner">
<a class="brand" href={localePath('/', locale)}>
<Moai />
{/* Below 420px the wordmark is what pushes the nav off the right edge. The
moai stays, and the name stays in the accessibility tree. */}
<span class="brand-text">Cashumints.space</span>
<span class="sr-only">Cashumints.space</span>
</a>
{
mintCount !== undefined && (
<a class="topbar-search" href={`${localePath('/mints', locale)}#mint-search`}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />
</svg>
{t('nav.searchMints', { n: mintCount })}
<kbd>/</kbd>
</a>
)
}
<nav class="nav" aria-label={t('nav.main')}>
{
links.map((link) => (
<a
href={localePath(link.path, locale)}
aria-current={current === link.key ? 'page' : undefined}
>
{link.label}
</a>
))
}
</nav>
{/*
The language control, next to the account one and in every layout.
In the header rather than only in the footer because of who needs it: a reader who
landed on a page in a language they do not read should not have to scroll the whole
of it to get out. The footer keeps its own copy, where someone deliberately looking
for site settings expects to find one.
*/}
<LanguageSwitcher variant="menu" />
{/*
The account control. Prerendered logged out, because a static build cannot know
who is reading; the island swaps in the avatar and name from the stored session
and the cached profile on first paint, before any relay is asked anything.
*/}
<div class="auth" data-auth>
<button class="auth-login" type="button" data-login-open>{t('login.open')}</button>
</div>
</div>
</header>
+73
View File
@@ -0,0 +1,73 @@
# Translation glossary
The terms this site keeps consistent across every language. Decide a term once here,
then use it everywhere: a reader who meets "reseña" on the home page and "opinión" on a
mint page has to work out whether they are the same thing.
Adding a fourth language starts here, not in the catalog. Fill in the column, then
translate `en.json` with it beside you.
## House rules, in every language
- **Sentence case.** Headings, buttons, labels. Not Title Case, not ALL CAPS.
(Small caps on section labels come from CSS, so the source string stays sentence case.)
- **No em dashes.** Ever, in any language. Use a comma, a colon, or two sentences.
Spanish and Dutch both reach for the em dash naturally; do not.
- **Specific over clever.** "Offline since 3 Aug 2026" beats "this mint has gone quiet".
- **Second person, informal.** Spanish `tú`, Dutch `je`. Never `usted`, never `u`.
- **Protocol terms stay English.** See the table.
- **Never translate data.** Review text, reviewer names, mint names, mint descriptions,
MOTD, URLs, npubs, version strings. Those are source data, not site copy.
## The terms
| English | Spanish | Dutch | Note |
| --- | --- | --- | --- |
| mint (noun) | el mint | de mint | Never "casa de la moneda" / "munt". It is the Cashu term. |
| mint (verb) | acuñar | minten | Spanish spells it out once per page then uses it plainly. |
| melt (verb) | fundir | melten | Same rule as mint. |
| minting | la acuñación | minten | |
| melting | el fundido | melten | |
| ecash | ecash | ecash | Never "dinero electrónico". |
| sat / sats | sat / sats | sat / sats | Never translated, never inflected. |
| Lightning | Lightning | Lightning | Capital L, no article games. |
| NUT / NUT-04 | NUT / NUT-04 | NUT / NUT-04 | The number is the name. The plain-language label beside it IS translated. |
| Nostr | Nostr | Nostr | |
| NIP-87, NIP-07, NIP-46 | unchanged | unchanged | |
| relay | el relay | de relay | Plural: relays. Not "retransmisor" / "doorgeefluik". |
| signer | el firmante | de signer | Dutch keeps the loanword: NL Nostr users say "signer". |
| review (the written thing) | la reseña | de review | |
| rating (the stars) | la valoración | de beoordeling | Deliberately a different word from review in all three. |
| reviewer | quien escribió la reseña | wie de review schreef | Avoid a noun that implies a role or an account. |
| wallet | el monedero | de wallet | Spanish follows bitcoin.org/es. Dutch keeps the loanword. |
| key (private/public) | la clave | de sleutel | |
| npub / pubkey | npub / pubkey | npub / pubkey | |
| custodial | custodial | custodiaal | Always followed by what it means, never left bare. |
| online (a mint) | en línea | online | |
| offline (a mint) | sin conexión | offline | Shorter than "fuera de línea" and standard for a status. |
| unreachable | no responde | onbereikbaar | |
| uptime | disponibilidad | uptime | |
| indexed | indexado | geïndexeerd | |
| to probe (a mint) | comprobar | controleren | |
| mint URL | la URL del mint | de mint-URL | |
| bunker link | el enlace bunker | de bunker-link | `bunker://` itself is never translated. |
| seed phrase | la frase semilla | de seed phrase | |
## Two decisions worth knowing
**Spanish spells out mint and melt on first use, then stops.** `acuñar (mint)` the first
time a page uses it, `acuñar` after that. Spanish-speaking Bitcoin readers know the
English verbs, but a page that only ever says "mint" reads as untranslated. Dutch does
not need this: `minten` and `melten` are what NL and BE Nostr users actually write.
**Dutch keeps more English than Spanish does.** `review`, `wallet`, `signer`, `online`,
`uptime` are all ordinary Dutch now, and translating them ("beoordeling" for the written
review, "portemonnee", "ondertekenaar") reads as stiff or, worse, as a different concept.
Spanish translates more, because Spanish tech writing does.
## Safety copy
The warning banners (`mint.warnings.*`) and the disclaimer are the copy someone reads
before they risk money. Translate them for **plain, unambiguous meaning**, not word for
word. If a literal translation is ambiguous about whether money can get out of a mint,
the translation is wrong even if every word is right. Short sentences. No hedging.
+74
View File
@@ -0,0 +1,74 @@
/**
* Island-side i18n. This is the file that ships.
*
* The page carries its own copy: `Base.astro` emits one `<script type="application/json"
* data-i18n>` holding the current locale's client-facing keys, already resolved against
* English. So a Spanish page ships Spanish strings and no others, in the HTML it was
* going to send anyway. No second request, no catalog chunk per language, no waterfall
* before an island can draw a label, and nothing in a shared JS chunk that has to be
* one language for everybody.
*
* The alternative, a dynamic `import('./es.json')` keyed on locale, also avoids
* shipping three languages, but it costs a round trip on the critical path of every
* island and it leaves all three catalogs sitting in `dist/_astro/`. This does not.
*/
import { DEFAULT_LOCALE, type Locale } from './config.js';
import { createTranslator, type Catalog, type Translator } from './translate.js';
interface Payload {
locale: Locale;
catalog: Catalog;
}
/**
* Read the payload out of the current document.
*
* Read every time rather than cached at module scope, because the view transition
* router swaps the document under this module without reloading it. An island that
* cached its translator on first load would keep drawing Spanish after the reader
* clicked through to a Dutch page. Islands call `useI18n()` inside their `onReady`
* setup, which runs again on every arrival, so this reads the page that is actually on
* screen.
*/
function readPayload(): Payload {
const tag = document.querySelector<HTMLScriptElement>('script[data-i18n]');
if (!tag) return { locale: DEFAULT_LOCALE, catalog: {} };
try {
const parsed = JSON.parse(tag.textContent ?? '{}') as Partial<Payload>;
return {
locale: parsed.locale ?? DEFAULT_LOCALE,
catalog: parsed.catalog ?? {},
};
} catch {
// A payload that will not parse is a build bug, not a runtime condition. Falling
// back to the key humaniser beats throwing inside every island on the page.
return { locale: DEFAULT_LOCALE, catalog: {} };
}
}
/**
* One translator per document, rebuilt when the router swaps in a new one.
*
* Keyed on the script element itself: a new page brings a new element, and identity
* comparison is cheaper and more honest than re-parsing the JSON on every `t()`.
*/
let cachedTag: Element | null = null;
let cached: Translator | null = null;
export function useI18n(): Translator {
const tag = document.querySelector('script[data-i18n]');
if (cached && tag === cachedTag) return cached;
const { locale, catalog } = readPayload();
// The payload is already resolved against English at build time, so the fallback
// catalog here is the same object: there is nothing further to fall back to.
cached = createTranslator(locale, catalog, catalog);
cachedTag = tag;
return cached;
}
/** The locale this page is in, for `Intl` calls that do not go through `t()`. */
export function currentLocale(): Locale {
return useI18n().locale;
}
+83
View File
@@ -0,0 +1,83 @@
/**
* The locale table. Adding a language is this file, `xx.json`, and nothing else.
*
* Everything downstream reads from `LOCALES`: the Astro i18n config, the language
* switcher, the hreflang set, the sitemap, the suggestion bar and the catalog loader.
* There is deliberately no second list to keep in step.
*/
export const DEFAULT_LOCALE = 'en' as const;
export interface LocaleInfo {
/** URL prefix and catalog filename. Also the value of `<html lang>`. */
code: string;
/**
* The language's own name for itself, for the switcher. Never a flag: a flag is a
* country, and Spanish is not Spain.
*/
label: string;
/**
* BCP 47 tag handed to Intl. Region included because number and date formatting
* differ by region even when the language does not.
*/
intl: string;
/** `og:locale`. Underscored, region required by the OpenGraph spec. */
og: string;
}
/**
* Order matters: this is the order the switcher renders in, and the default locale
* comes first.
*/
export const LOCALES: LocaleInfo[] = [
{ code: 'en', label: 'English', intl: 'en-GB', og: 'en_US' },
{ code: 'es', label: 'Español', intl: 'es-ES', og: 'es_ES' },
{ code: 'nl', label: 'Nederlands', intl: 'nl-NL', og: 'nl_NL' },
];
export type Locale = string;
/** Just the codes, for the Astro config and for validating a URL segment. */
export const LOCALE_CODES: string[] = LOCALES.map((l) => l.code);
/** Every locale except the default, which is the set that carries a URL prefix. */
export const PREFIXED_LOCALES: string[] = LOCALE_CODES.filter((code) => code !== DEFAULT_LOCALE);
export function isLocale(value: unknown): value is Locale {
return typeof value === 'string' && LOCALE_CODES.includes(value);
}
export function localeInfo(locale: Locale): LocaleInfo {
return LOCALES.find((l) => l.code === locale) ?? LOCALES[0]!;
}
/** The Intl tag for a locale, for `Intl.NumberFormat` and friends. */
export function intlTag(locale: Locale): string {
return localeInfo(locale).intl;
}
/**
* Namespaces whose keys are sent to the browser.
*
* Islands render strings at runtime, so their copy has to reach the client. The static
* half of the site (home page sections, about, the legal pages) is rendered at build
* time and its copy never needs to ship. Splitting on the namespace keeps the inline
* payload to roughly a third of the catalog.
*
* `scripts/check-i18n.mjs` fails the build if an island reaches for a key outside this
* list, so the split cannot silently rot into a missing string on a real page.
*/
export const CLIENT_NAMESPACES = [
'common',
'time',
'status',
'pulse',
'mints',
'mint',
'nut',
'reviews',
'feed',
'login',
'signer',
'notfound',
] as const;
+550
View File
@@ -0,0 +1,550 @@
{
"site.name": "Cashumints.space",
"common.close": "Close",
"common.cancel": "Cancel",
"common.done": "Done",
"common.back": "Back",
"common.retry": "Retry",
"common.connect": "Connect",
"common.copied": "Copied",
"common.copyFull": "Copy full {kind}",
"common.previousPage": "Previous page",
"common.nextPage": "Next page",
"common.reviewPages": "Review pages",
"time.never": "never",
"time.justNow": "just now",
"time.none": "-",
"time.unknownDate": "an unknown date",
"time.ago.minute": "{n}m ago",
"time.ago.hour": "{n}h ago",
"time.ago.day": "{n}d ago",
"time.ago.week": "{n}w ago",
"time.ago.month": "{n}mo ago",
"time.ago.year": "{n}y ago",
"time.short.minute": "{n}m",
"time.short.hour": "{n}h",
"time.short.day": "{n}d",
"time.short.week": "{n}w",
"time.short.month": "{n}mo",
"time.short.year": "{n}y",
"status.online": "Online",
"status.offline": "Offline",
"status.degraded": "Unreachable",
"status.unknown": "Not checked yet",
"nav.skip": "Skip to content",
"nav.main": "Main",
"nav.mints": "Mints",
"nav.reviews": "Reviews",
"nav.wallets": "Wallets",
"nav.about": "About",
"nav.searchMints": "Search {n} mints",
"lang.label": "Language",
"lang.switcher": "Choose a language",
"footer.copyright": "© {year} Cashumints.space",
"footer.allMints": "All mints",
"footer.allReviews": "All reviews",
"footer.wallets": "Wallets",
"footer.about": "About",
"footer.disclaimer": "Disclaimer",
"footer.terms": "Terms",
"footer.privacy": "Privacy",
"footer.madeBy": "Made with ❤️ by {azzamo} · Powered by Cashu and NIP-87",
"footer.warning": "Mints are custodial. A mint can disappear with your money.",
"home.title": "Cashumints.space · Find a Cashu mint you can trust",
"home.description": "Find a Cashu mint you can trust. {mints} mints indexed with {reviews} community reviews signed on Nostr, plus live uptime, supported NUTs and mint software.",
"home.hero.title": "Find a Cashu mint",
"home.hero.titleEm": "you can trust",
"home.hero.lede": "Every mint on the Nostr network, with real community reviews. No gatekeepers, just signed reviews you can verify yourself.",
"home.search.label": "Search mints by name, domain, or URL",
"home.search.placeholder": "Search by name, domain, or paste a mint URL…",
"home.filters.mostReviewed": "Most reviewed",
"home.filters.highestRated": "Highest rated",
"home.filters.recentlyActive": "Recently active",
"home.filters.websockets": "Supports WebSockets",
"home.top.title": "Top mints",
"home.top.sub": "ranked by reviews and rating",
"home.top.all": "All {n} mints →",
"home.latest.title": "Latest reviews",
"home.latest.sub": "live from the Nostr network",
"home.latest.prev": "Previous reviews",
"home.latest.next": "More reviews",
"home.latest.all": "All reviews →",
"home.latest.empty": "No reviews could be read from the relays just now. Open any mint page to read and write reviews directly.",
"home.why.title": "Why trust these reviews?",
"home.why.signed.title": "Signed, not submitted",
"home.why.signed.body": "Every review is a {link} event signed by its author's Nostr key. Nothing here is edited, ranked, or removed by us.",
"home.why.signed.link": "NIP-87",
"home.why.verify.title": "Verify it yourself",
"home.why.verify.body": "Reviews live on public Nostr relays, not our database. Point any client at the same relays and you'll see the same data.",
"home.why.checked.title": "Checked continuously",
"home.why.checked.body": "We probe every mint for uptime, software version, limits, and supported NUTs, so the specs you see are current, not self-reported.",
"home.cta.title": "Thinking about a mint? Read it first.",
"home.cta.body": "A mint holds your ecash. Read what other people found before you put sats in, and leave a review when you have used one yourself.",
"home.cta.explore": "Explore all mints",
"home.cta.learn": "Learn about Cashu",
"pulse.mintsIndexed": "mints indexed",
"pulse.reviewsOnNostr": "reviews on Nostr",
"pulse.lastReview": "last review",
"pulse.mintsOnline": "mints online",
"pulse.indexer": "indexer",
"pulse.connected": "connected",
"pulse.stale": "stale",
"mints.title": "All Cashu mints · Cashumints.space",
"mints.description": "All {total} Cashu mints indexed from the Nostr network, {online} online and {offline} offline, with community ratings, uptime and mint software. Search and sort the full list.",
"mints.heading": "All mints",
"mints.lede": "Every mint discovered on the Nostr network, including the ones that are gone. Offline mints stay listed so you can still find and review them.",
"mints.search.label": "Search mints by name or domain",
"mints.search.placeholder": "Search by name or domain…",
"mints.sort.label": "Sort mints",
"mints.sort.score": "Best overall",
"mints.sort.rating": "Highest rated",
"mints.sort.reviews": "Most reviewed",
"mints.sort.recent": "Recently reviewed",
"mints.sort.seen": "Recently seen online",
"mints.hideOffline": "Hide offline",
"mints.showing": "Showing {total} mints ({online} online, {offline} offline)",
"mints.showingFiltered": "Showing {shown} of {total} mints ({online} online, {offline} offline)",
"mints.noResults": "No mints match. Try a domain, or paste a mint URL.",
"mints.clearSearch": "Clear the search",
"card.noRatings": "No ratings yet",
"card.reviews.one": "{n} review",
"card.reviews.other": "{n} reviews",
"card.lastSeen": "last seen {when}",
"card.neverSeen": "never seen online",
"card.reviewed": "reviewed {when}",
"card.noReviews": "no reviews yet",
"mint.title": "{name} · Cashu mint reviews",
"mint.description.fallback": "{name} is a Cashu mint at {domain}.",
"mint.description.rated.one": "Rated {rating} out of 5 from {n} review signed on Nostr.",
"mint.description.rated.other": "Rated {rating} out of 5 from {n} reviews signed on Nostr.",
"mint.description.noReviews": "No community reviews yet.",
"mint.description.online": "Currently online.",
"mint.description.unreachable": "Currently unreachable.",
"mint.breadcrumb": "Breadcrumb",
"mint.crumbMints": "Mints",
"mint.copyUrl": "Copy mint URL",
"mint.qrButton": "Mint QR",
"mint.writeReview": "Write a review",
"mint.rankByScore": "#{rank} by score",
"mint.tagLightning": "Lightning",
"mint.summary": "Mint summary",
"mint.rating.label": "Community rating",
"mint.rating.none": "No ratings yet",
"mint.rating.reviews.one": "{n} review",
"mint.rating.reviews.other": "{n} reviews",
"mint.rating.unrated.one": "{n} left no rating",
"mint.rating.unrated.other": "{n} left no rating",
"mint.activity.label": "Activity",
"mint.activity.since": "since last review",
"mint.activity.noReviews": "no reviews yet",
"mint.activity.recent.one": "{n} review in the last 90 days",
"mint.activity.recent.other": "{n} reviews in the last 90 days",
"mint.limits.label": "Limits",
"mint.limits.notPublished": "not published",
"mint.limits.min": "min",
"mint.limits.max": "max",
"mint.limits.mintRow": "mint",
"mint.limits.meltRow": "melt",
"mint.limits.disabled": "disabled",
"mint.limits.range": "{min} to {max} {unit}",
"mint.limits.upTo": "up to {max} {unit}",
"mint.limits.from": "from {min} {unit}",
"mint.software.label": "Mint software",
"mint.software.unknown": "unknown",
"mint.checked": "checked {when}",
"mint.lastSeen": "last seen {when}",
"mint.neverReached": "never reached",
"mint.uptime.checks": "{ok} of the last {total} checks reachable",
"mint.uptime.percent": "{percent}% reachable over 30 days",
"mint.uptime.description": "{ok} of the last {total} checks succeeded",
"mint.operator.title": "From the operator",
"mint.operator.motd": "Message of the day",
"mint.operator.beta": "This mint describes itself as experimental or in beta. Read the operator's own words below before storing anything you cannot lose.",
"mint.nuts.title": "Supported NUTs",
"mint.nuts.noneOffline": "No NUT list cached for this mint, it went offline before one was recorded.",
"mint.nuts.noneYet": "No NUT list cached for this mint yet.",
"mint.nuts.supported": "{n} of {total} known NUTs supported",
"mint.nuts.whatAre": "what are NUTs?",
"mint.nutRow.disabled": "disabled",
"mint.nutRow.notSupported": "not supported",
"mint.details.title": "Mint details",
"mint.details.pubkey": "Pubkey",
"mint.details.nostr": "Nostr",
"mint.details.email": "Email",
"mint.details.serverTime": "Server time",
"mint.details.firstSeen": "First seen",
"mint.details.terms": "Terms",
"mint.kind.identifier": "identifier",
"mint.kind.mintPubkey": "mint pubkey",
"mint.kind.nostrAddress": "Nostr address",
"mint.kind.emailAddress": "email address",
"mint.kind.npub": "npub",
"mint.disclaimer": "This mint is custodial: it holds your money, and it can stop doing so at any time. Ratings are community opinions and online means reachable, not trustworthy.",
"mint.disclaimerLink": "Read the full disclaimer",
"mint.qr.title": "Add {name} to your wallet",
"mint.qr.note": "Scan this with a Cashu wallet to add the mint, or copy the URL and paste it in.",
"mint.qr.warn": "Adding a mint means trusting it with your money.",
"mint.qr.whatMeans": "What that means",
"mint.qr.wallets": "Wallets that support Cashu",
"mint.chip.frozen": "Frozen",
"mint.chip.noWithdrawals": "No withdrawals",
"mint.chip.meltOnly": "Melt only",
"mint.warnings.dayCount.one": "{n} day",
"mint.warnings.dayCount.other": "{n} days",
"mint.warnings.gone.lead": "Likely gone.",
"mint.warnings.gone.body.since": "This mint has been unreachable since {date} ({days}). Treat funds held there as at risk, and do not send new funds. Details below show its last known state.",
"mint.warnings.gone.body.neverDated": "This mint has not answered a single check since we started watching it in {month}. Treat funds held there as at risk, and do not send new funds. Anything below came from the Nostr announcement, not from the mint.",
"mint.warnings.gone.body.never": "This mint has not answered a single check since we started watching it. Treat funds held there as at risk, and do not send new funds. Anything below came from the Nostr announcement, not from the mint.",
"mint.warnings.gone.meta.since": "Offline since {date}",
"mint.warnings.gone.meta.never": "Never reachable since it was discovered",
"mint.warnings.meltDisabled.lead": "Withdrawals disabled.",
"mint.warnings.meltDisabled.body.online": "This mint has Lightning melting (NUT-05) turned off: you can put sats in, but you cannot withdraw them via Lightning. Do not deposit until melting is back.",
"mint.warnings.meltDisabled.body.offline": "This mint had Lightning melting (NUT-05) turned off: you can put sats in, but you cannot withdraw them via Lightning. Do not deposit until melting is back.",
"mint.warnings.meltDisabled.meta.online": "Withdrawals currently disabled",
"mint.warnings.meltDisabled.meta.offline": "Withdrawals were disabled when last seen",
"mint.warnings.frozen.lead": "Mint frozen.",
"mint.warnings.frozen.body.online": "Both minting and melting are disabled. No sats can move in or out via Lightning.",
"mint.warnings.frozen.body.offline": "Both minting and melting were disabled. No sats can move in or out via Lightning.",
"mint.warnings.frozen.meta.online": "Currently frozen, nothing moves in or out",
"mint.warnings.frozen.meta.offline": "Frozen when last seen",
"mint.warnings.offlineLong.lead": "Offline for {days}, since {date}.",
"mint.warnings.offlineLong.body": "The mint may be gone. Everything below is its last known state, and reviews still work.",
"mint.warnings.offlineLong.meta": "Offline since {date}",
"mint.warnings.meltOnly.lead": "Melt only.",
"mint.warnings.meltOnly.body.online": "New minting (NUT-04) is disabled: you cannot deposit, but existing ecash can still be withdrawn. This often means a mint is winding down, so if you hold its ecash, consider melting out.",
"mint.warnings.meltOnly.body.offline": "New minting (NUT-04) was disabled: you cannot deposit, but existing ecash can still be withdrawn. This often means a mint is winding down, so if you hold its ecash, consider melting out.",
"mint.warnings.meltOnly.meta.online": "Currently melt only",
"mint.warnings.meltOnly.meta.offline": "Melt only when last seen",
"mint.warnings.offline.lead": "Offline since {date}.",
"mint.warnings.offline.body": "Details were last confirmed then. You can still write a review.",
"mint.warnings.offline.meta": "Offline since {date}",
"mint.warnings.also.meltDisabled": "Before going offline it had also disabled withdrawals.",
"mint.warnings.also.mintDisabled": "Before going offline it had also disabled new minting.",
"mint.warnings.also.offline": "It has also been offline since {date} ({days}).",
"nut.0": "Notation and models",
"nut.1": "Mint public key exchange",
"nut.2": "Keysets and fees",
"nut.3": "Swapping tokens",
"nut.4": "Minting via Lightning",
"nut.5": "Melting via Lightning",
"nut.6": "Mint information",
"nut.7": "Token state check",
"nut.8": "Overpaid Lightning fees",
"nut.9": "Restore signatures",
"nut.10": "Spending conditions",
"nut.11": "Pay to public key",
"nut.12": "DLEQ proofs",
"nut.13": "Deterministic secrets",
"nut.14": "Hashed timelock contracts",
"nut.15": "Multipath payments",
"nut.16": "Animated QR codes",
"nut.17": "WebSocket subscriptions",
"nut.18": "Payment requests",
"nut.19": "Cached responses",
"nut.20": "Signed mint quotes",
"nut.21": "Clear authentication",
"nut.22": "Blind authentication",
"nut.23": "BOLT11 payment method",
"nut.24": "HTTP 402 payment required",
"nut.25": "BOLT12 payment method",
"reviews.title": "Reviews",
"reviews.anon": "Anon",
"reviews.filter.group": "Filter reviews",
"reviews.filter.all": "All",
"reviews.filter.five": "5★",
"reviews.filter.critical": "Critical",
"reviews.filter.recent": "Recent",
"reviews.loading.one": "Loading {n} review from Nostr relays",
"reviews.loading.other": "Loading {n} reviews from Nostr relays",
"reviews.empty": "No reviews yet. Be the first to review this mint.",
"reviews.emptyWritten": "No written reviews here yet. Be the first to write one.",
"reviews.emptyFilter": "No reviews match this filter.",
"reviews.noscript": "Reviews are signed on Nostr and load in your browser, so this list needs JavaScript. The rating and counts above come from the site itself.",
"reviews.relayError": "Could not reach the relay. Reviews may be incomplete.",
"reviews.srRating": "{rating} out of 5",
"reviews.srNoRating": "No rating given",
"reviews.anonNote": "First review from this npub, no other activity found",
"reviews.published": "Published.",
"reviews.publishedPartial": "Published to {accepted} of {total} relays.",
"reviews.propagating": "Propagating, it may take a moment to appear elsewhere.",
"reviews.summary.line.one": "{count} rating without a comment: {breakdown}",
"reviews.summary.line.other": "{count} ratings without a comment: {breakdown}",
"reviews.summary.lineDay.one": "{count} rating without a comment on {when}: {breakdown}",
"reviews.summary.lineDay.other": "{count} ratings without a comment on {when}: {breakdown}",
"reviews.summary.showOne": "Show it",
"reviews.summary.showMany": "Show them",
"reviews.breakdown.5": "{n} five star",
"reviews.breakdown.4": "{n} four star",
"reviews.breakdown.3": "{n} three star",
"reviews.breakdown.2": "{n} two star",
"reviews.breakdown.1": "{n} one star",
"reviews.breakdown.none": "{n} with no rating",
"reviews.dialog.title": "Review {name}",
"reviews.dialog.note": "Signed with your Nostr key and published to public relays. It cannot be edited or deleted.",
"reviews.dialog.ratingLegend": "Your rating",
"reviews.dialog.starsSr.one": "{n} star",
"reviews.dialog.starsSr.other": "{n} stars",
"reviews.dialog.chooseRating": "Choose a rating",
"reviews.dialog.starLabel": "{n}: {phrase}",
"reviews.dialog.stars.1": "avoid it",
"reviews.dialog.stars.2": "had problems",
"reviews.dialog.stars.3": "worked, with caveats",
"reviews.dialog.stars.4": "good",
"reviews.dialog.stars.5": "works great",
"reviews.dialog.bodyLabel": "What should other people know?",
"reviews.dialog.bodyPlaceholder": "How did minting, melting and support work out? Be specific.",
"reviews.dialog.charCount": "{n} / {max}",
"reviews.dialog.submit": "Sign and publish",
"reviews.dialog.signing": "Signing…",
"reviews.dialog.publishing": "Publishing…",
"reviews.dialog.publishingTo": "Publishing to relays ({accepted}/{total})",
"reviews.dialog.waitingExtension": "Waiting for your extension",
"reviews.dialog.waitingSigner": "Waiting for your signer",
"reviews.dialog.confirmClose": "Close this review? Your draft is kept until you leave the site.",
"reviews.dialog.why.login": "Log in to publish",
"reviews.dialog.why.rating": "Choose a rating",
"reviews.dialog.why.words": "Write a few words",
"reviews.dialog.why.more.one": "{n} more character",
"reviews.dialog.why.more.other": "{n} more characters",
"reviews.dialog.why.over.one": "{n} character over the limit",
"reviews.dialog.why.over.other": "{n} characters over the limit",
"reviews.dialog.identity.needSigner": "You need a Nostr signer to publish. Your key stays in it, this site never sees it.",
"reviews.dialog.identity.loginToPublish": "Log in to publish",
"reviews.dialog.identity.switch": "Switch",
"reviews.dialog.identity.extension": "Signing with your browser extension",
"reviews.dialog.identity.readingKey": "Reading your public key…",
"reviews.dialog.identity.willAsk": "Your extension will be asked to sign",
"reviews.dialog.error.noRelay": "No relay accepted the review. Check your connection, then try again.",
"reviews.validate.rating": "Choose a rating from 1 to 5 stars.",
"reviews.validate.short": "Write at least {n} characters so the review is useful.",
"reviews.validate.long": "Reviews are limited to {n} characters.",
"feed.title": "Latest Cashu mint reviews · Cashumints.space",
"feed.description": "Every Cashu mint review as it is published: {reviews} reviews across {mints} mints, signed on Nostr and readable here newest first. Filter by rating or by mint.",
"feed.heading": "Latest Cashu mint reviews",
"feed.lede": "Every review of every mint, newest first, read live from the Nostr relays. Each one is signed by its author and lives on the relays, not in our database.",
"feed.label": "Reviews",
"feed.filterByMint": "Filter by mint",
"feed.anyMint": "Any mint",
"feed.clearMint": "Clear the mint filter",
"feed.showingBuild.one": "Showing the most recent review from the last build",
"feed.showingBuild.other": "Showing the {n} most recent reviews from the last build",
"feed.noscript": "Reviews are signed on Nostr and load in your browser, so filtering and the full list need JavaScript. The {n} reviews below were read from the relays when this page was built.",
"feed.emptyBuild": "No reviews could be read from the relays when this page was built. The list reloads from the relays in your browser.",
"feed.foot": "Reviews are community opinions, not verified claims. Read the {link} before you trust a mint with money.",
"feed.footLink": "disclaimer",
"feed.showing": "Showing {shown} of {total} reviews",
"feed.showingMatching": "Showing {shown} of {matching} matching reviews ({total} in total)",
"feed.emptyRelays": "No reviews came back from the relays. Open any mint page to read and write reviews there.",
"feed.emptyFilter": "No reviews match this filter. Clear the mint box, or try All.",
"feed.relayError": "Could not reach the relays. Showing the reviews from the last build.",
"feed.relayEmpty": "No reviews came back from the relays. Showing the reviews from the last build.",
"feed.reading": "Reading the relays…",
"login.open": "Log in",
"login.title": "Log in with Nostr",
"login.note": "Your key stays in your signer. This site can never see it.",
"login.extension.name": "Browser extension",
"login.extension.desc": "Sign with Alby, nos2x or another NIP-07 extension",
"login.extension.none": "No extension found",
"login.extension.getOne": "Get one:",
"login.remote.name": "Remote signer",
"login.remote.desc": "Scan a code with a signer app, or paste a bunker link",
"login.primal.name": "Primal",
"login.primal.desc": "Approve the login in the Primal app on your phone",
"login.paste.label": "Or paste the connection link your signer gave you",
"login.copyLink": "Copy the connection link",
"login.whatIsNostr": "What is Nostr?",
"login.connect.remote.title": "Connect a remote signer",
"login.connect.remote.lead": "Scan this with your signer app, or copy the link and paste it there.",
"login.connect.remote.hint": "Works with any NIP-46 signer: nsec.app, Amber, Nostrify, your own bunker.",
"login.connect.primal.title": "Connect with Primal",
"login.connect.primal.lead": "Open Primal on your phone, find remote signing in its settings, and scan this code.",
"login.connect.primal.hint": "Primal signs for other apps over NIP-46, so this is the same connection any signer uses, and your key stays in Primal. {link}",
"login.status.buildingLink": "Building a connection link",
"login.status.waitingExtension": "Waiting for your extension",
"login.status.waitingSigner": "Waiting for your signer",
"login.status.connecting": "Connecting to your signer",
"login.status.connected": "Connected",
"login.method.extension": "Extension",
"login.method.remote": "Remote",
"login.method.primal": "Primal",
"login.account": "Your Nostr account",
"login.copyNpub": "Copy your full npub",
"login.logout": "Log out",
"signer.status.waitingExtension": "Waiting for your extension",
"signer.status.signed": "Signed",
"signer.status.reconnecting": "Reconnecting to your signer",
"signer.status.approveWindow": "Your signer needs you to approve this in its own window",
"signer.status.waitingSigner": "Waiting for your signer",
"signer.status.stillWaiting": "Still waiting for your signer to answer",
"signer.status.connected": "Connected",
"signer.error.noExtension": "No signing extension found. Install Alby or nos2x, then reload this page.",
"signer.error.privateKey": "That looks like a private key, not a connection link. This site never takes a key. Paste the bunker:// link your signer gave you.",
"signer.error.badLink": "That is not a connection link this site understands. Paste the bunker:// link from your signer.",
"signer.error.noAnswer": "Your signer did not answer. It may be closed, or its relay may be down. Open it and try again.",
"signer.error.timeout": "Your signer did not answer in time. Open it, check it is still connected, then try again.",
"signer.error.cancelled": "Connection cancelled.",
"signer.error.declined": "Signature request was declined",
"notfound.title": "Page not found · Cashumints.space",
"notfound.description": "That page does not exist on Cashumints.space.",
"notfound.heading": "This page does not exist",
"notfound.lede": "It may have moved, or the mint may not be indexed yet. Search for a mint by name or domain, or paste a mint URL.",
"notfound.browse": "Browse all mints",
"notfound.home": "Go home",
"notfound.loading": "Loading {host}",
"notfound.fb.rating": "Rating",
"notfound.fb.reviews": "Reviews",
"notfound.fb.software": "Software",
"notfound.fb.lastOnline": "Last online",
"notfound.fb.note": "This mint was indexed after the site was last built, so this is a summary from the live API. The full page with reviews appears at the next rebuild.",
"wallets.title": "Cashu wallets · Cashumints.space",
"wallets.description": "Cashu wallets that work with the mints listed here, across mobile, web, browser and the command line, with what each one is good at.",
"wallets.heading": "Wallets",
"wallets.lede": "You need a wallet to hold ecash from any of these mints. Most wallets talk to several mints at once, so you are not tied to the first one you try.",
"wallets.note": "This list is not exhaustive and is not an endorsement. The Cashu project keeps a broader index at {link}. A wallet holds keys, a mint holds the money: read the mint reviews before you pick where your sats live.",
"wallets.platform.android": "Android",
"wallets.platform.ios": "iOS",
"wallets.platform.web": "Web",
"wallets.platform.extension": "Browser extension",
"wallets.platform.cli": "CLI",
"wallets.platform.python": "Python",
"wallets.item.minibits": "Mobile ecash wallet with its own mint, Lightning address support and Nostr contacts.",
"wallets.item.nutstash": "Browser wallet that handles multiple mints at once and imports and exports raw tokens.",
"wallets.item.enuts": "Mobile wallet focused on multimint balances and sending ecash over Nostr.",
"wallets.item.cashume": "Reference web wallet from the Cashu project. Runs entirely in the browser, installable as a PWA.",
"wallets.item.boardwalk": "Web wallet built around Lightning addresses and sending ecash by link.",
"wallets.item.coinos": "Lightning wallet and point of sale that also runs its own Cashu mint.",
"wallets.item.oxchat": "Nostr messenger with an ecash wallet built in, for sending sats inside chats.",
"wallets.item.nutshell": "The reference implementation. Runs as a wallet on the command line and as mint software.",
"about.title": "About Cashumints.space",
"about.description": "What Cashu is, what a mint actually holds, what NIP-87 is, and how this site indexes mints and reads reviews from Nostr relays.",
"about.heading": "About",
"about.lede": "Cashumints.space lists every Cashu mint we can find on the Nostr network and shows you what people say about them. It is made by Azzamo.",
"about.cashu.title": "What Cashu is",
"about.cashu.p1": "Cashu is an ecash protocol for Bitcoin. A mint issues blinded tokens backed by sats it holds, and those tokens move between people without the mint learning who sent what to whom. The privacy is real and it is the point: tokens are not accounts, and the mint cannot link the token it signed to the token that comes back.",
"about.cashu.p2": "You can read the protocol and its specifications at {link}. The specifications themselves are called NUTs, which is where the NUT numbers on every mint page come from.",
"about.mints.title": "What a mint is, and the tradeoff",
"about.mints.p1": "A mint is custodial. When you mint ecash you are handing sats to whoever runs that server, and you are trusting them to honour the tokens when you melt them back out. There is no fraud proof, no escrow and no recourse. A mint operator can disappear with the balance, and mints have.",
"about.mints.p2": "That is exactly why reviews exist here. This site never tells you a mint is safe. The status on a mint page says {code}, not \"trusted\", and it only means the server answered when we last asked. Treat ecash balances like cash in a coat pocket: fine for spending, wrong for savings.",
"about.nip87.title": "What NIP-87 is",
"about.nip87.p1": "NIP-87 is the Nostr convention for announcing and recommending ecash mints. Two event kinds matter here:",
"about.nip87.li1": "{code} announces a mint. The mint URL is in the {tag} tag. This is how the site discovers mints it has never seen.",
"about.nip87.li2": "{code} is a review. It carries the rating and the review text, and points at the mint it is about with a {utag} tag (the mint URL) and a {dtag} tag (the mint's own public key).",
"about.nip87.p2": "Every review is signed by its author's Nostr key. We never call a review \"verified\", because nobody verified it: it is signed, which is a different and more useful claim. It means you can check who wrote it and that it was not altered. It does not mean the author is honest or ever used the mint.",
"about.how.title": "How this site works",
"about.how.p1": "There are two loops and one rule. The rule is that the mint owns its metadata and Nostr owns the reviews, so this site stores neither as an authority.",
"about.how.li1": "{strong} asks every known mint for {code} every ten minutes and records what it says: name, description, version, supported NUTs, limits, contact details. It also records whether the mint answered at all, which is where uptime comes from.",
"about.how.li1Label": "The probe loop",
"about.how.li2": "{strong} connects to Nostr relays every hour, reads mint announcements and reviews, and adds any mint it has not seen before.",
"about.how.li2Label": "The discovery loop",
"about.how.p2": "The important part is what happens when a mint stops answering. Its cached metadata is never overwritten by a failed probe, so a dead mint keeps its full page: its specs, its history and its reviews, with a banner saying when it went dark. The previous version of this site showed an error page instead, which meant the mints people most needed to warn each other about were the ones that could not be reviewed. That was the main reason for the rebuild.",
"about.how.p3": "Review text is never stored on our side. When you open a mint page your browser talks to the relays directly and reads the reviews itself, and when you write one it is signed by your own Nostr extension and sent straight to those relays. We keep a count so the pages can be ranked and sorted, nothing more. Nothing here can be edited or deleted by us, including reviews we would rather not have.",
"about.relays.title": "Relays we read",
"about.relays.p1": "These are the relays the indexer and your browser both use:",
"about.relays.p2": "Point any Nostr client at the same relays and you will see the same events. That is the whole promise: nothing here is only true because we say so.",
"about.nuts.title": "What are NUTs?",
"about.nuts.p1": "NUTs are the numbered Cashu specifications, like BIPs for Bitcoin. A mint advertises which ones it implements, and that tells you what it can actually do: NUT-04 and NUT-05 are minting and melting over Lightning (without them a mint is not much use), NUT-07 lets a wallet check whether a token was already spent, NUT-17 gives wallets live updates over WebSockets instead of polling, and NUT-20 lets a wallet prove a mint quote was really issued to it. The full list is at {link}.",
"about.numbers.title": "Right now",
"about.numbers.p1": "{mints} mints indexed, {online} of them answering, {reviews} reviews counted from the relays above.",
"about.source.title": "Source and credit",
"about.source.p1": "The site is open source at {github}. It is built and run by {azzamo}. If a mint page has something wrong on it, fix it at the source: mint metadata comes from the mint's own {code}, and reviews come from Nostr.",
"terms.title": "Terms of use · Cashumints.space",
"terms.description": "Terms of use for Cashumints.space: what the site is, what it does not do, and who is responsible for what. No accounts, no warranties, no affiliation with any mint.",
"terms.heading": "Terms of use",
"terms.lede": "Plain terms for a site that only indexes public data. Last updated {date}.",
"terms.what.title": "What this site is",
"terms.what.p1": "Cashumints.space is an index of publicly available data about Cashu mints. Two kinds of data, both from somewhere else:",
"terms.what.li1": "Mint metadata, read from each mint's own {code} endpoint: name, description, software version, supported NUTs, contact details and whether the mint answered when we last asked.",
"terms.what.li2": "Reviews, read from public Nostr relays. These are NIP-87 events published by their authors to relays anyone can query.",
"terms.what.p2": "We collect that data, normalise it, rank it and show it. We do not create it.",
"terms.noAccounts.title": "No accounts, no warranties",
"terms.noAccounts.p1": "There is nothing to sign up for. The site is provided as it is, with no warranty of any kind: not that the data is accurate, not that it is current, not that the site will be available. Mint data can be minutes or hours old, a mint can change after we last checked, and a relay can fail to answer, in which case you are seeing less than exists.",
"terms.noAccounts.p2": "Use it at your own risk. We are not liable for any loss that follows from using this site or relying on anything you read here. See the {link} for the specific risks of using a Cashu mint.",
"terms.noAccounts.link": "disclaimer",
"terms.noCustody.title": "We do not hold your money",
"terms.noCustody.p1": "This site never takes custody of funds. It has no wallet, it does not touch ecash, and it cannot move, freeze or recover anything. If you have a problem with a mint, the mint operator is the only party who can help you, and we cannot contact them on your behalf.",
"terms.noCustody.p2": "We are not affiliated with any mint listed here, and no mint pays for its listing, its rank or its rating. Mints are discovered from public Nostr announcements and ranked by an algorithm over public review data.",
"terms.reviews.title": "Reviews are other people's words",
"terms.reviews.p1": "Reviews are user-generated content published to public Nostr relays by their authors. We display them, we do not control them. We did not write them, we cannot edit them, and we cannot delete them from the relays that hold them: they are signed events on a network we do not own.",
"terms.reviews.p2": "A review is one person's experience, not a verified fact. If you publish one, you are responsible for what it says. Do not publish anything unlawful, and be aware that what you publish is public and permanent (see the {link}).",
"terms.reviews.link": "privacy page",
"terms.reviews.p3": "We can stop showing a specific review on this site, and we will do that for content that is unlawful. That removes it from these pages only. The event stays on the relays and any other Nostr client can still read it.",
"terms.links.title": "Links to other sites",
"terms.links.p1": "Mint URLs, wallet links, operator contacts and everything else that points off this site are there because they are part of the data. A link is not an endorsement and we do not vet what is on the other end.",
"terms.changes.title": "Changes",
"terms.changes.p1": "These terms can change. The date at the top says when they last did. The site is open source, so every change to this page is in the commit history.",
"terms.contact.title": "Contact",
"terms.contact.p1": "Issues and takedown requests are best filed on {github}. The site is run by {azzamo}.",
"privacy.title": "Privacy · Cashumints.space",
"privacy.description": "Privacy on Cashumints.space: no accounts, no tracking cookies, no analytics. localStorage is used for profile caching and nothing is sent anywhere. Reviews are signed in your own browser.",
"privacy.heading": "Privacy",
"privacy.lede": "We collect as little as a website can. Last updated {date}.",
"privacy.noAccounts.title": "No accounts",
"privacy.noAccounts.p1": "There is no sign up, no login and no profile. Nothing on this site asks you who you are, and there is no user record to leak because there are no users.",
"privacy.noTracking.title": "No tracking cookies, no analytics",
"privacy.noTracking.p1": "The site sets no cookies for tracking. There is no analytics script, no tag manager, no pixel, no session recording and no advertising network. Nothing on the page reports your behaviour to us or to anyone else.",
"privacy.logs.title": "Server logs",
"privacy.logs.p1": "The web server keeps standard access logs, which is what any web server does by default. A log line records your IP address, the time, the page you asked for, the response status, and the user agent and referrer your browser sent. The logs exist to debug errors and spot abuse. They are not joined to anything else and they are not sold or shared.",
"privacy.logs.operatorLabel": "Operator note:",
"privacy.logs.operator": "if you run this site, state your real retention here. The deployment in the README uses nginx with its default access log and whatever rotation your distribution ships (on Debian and Ubuntu that is logrotate, daily, keeping 14 days). Confirm what your server does and replace this paragraph with the number.",
"privacy.storage.title": "What is stored in your browser",
"privacy.storage.p1": "The site uses localStorage for exactly two things, both of which stay on your device and are never sent anywhere:",
"privacy.storage.li1": "A cache of Nostr profile metadata (kind 0 events: names, avatars, NIP-05 addresses) for the reviewers whose reviews you have looked at. This saves re-querying relays on every page. Entries expire, and the cache is capped and evicted oldest first.",
"privacy.storage.li2": "Interface preferences, so the site behaves the same way next time.",
"privacy.storage.p2": "Clearing your browser's site data removes both. Nothing breaks, the profile lookups just run again.",
"privacy.thirdParties.title": "What your browser talks to",
"privacy.thirdParties.p1": "Loading a page connects your browser to a few places besides this site. That is how a site with no server-side tracking still reads live data:",
"privacy.thirdParties.li1Label": "Public Nostr relays",
"privacy.thirdParties.li1": "{strong}, over WebSocket, to read reviews and reviewer profiles. The relay sees your IP and what you asked for, as any server you connect to does. The relays are listed on the {link}.",
"privacy.thirdParties.li1Link": "about page",
"privacy.thirdParties.li2Label": "Reviewer avatar images",
"privacy.thirdParties.li2": "{strong}, loaded from wherever the reviewer's profile points. These are sent with a no-referrer policy, so the host does not learn which page you were on.",
"privacy.publishing.title": "Publishing a review",
"privacy.publishing.p1": "Writing a review happens in your browser, not on our server. The event is signed by your NIP-07 extension (Alby, nos2x or similar) and broadcast from your browser straight to the relays. Your private key never leaves the extension and this site never sees it. We do not build custom key management and we never ask for a key or a seed phrase.",
"privacy.permanent.title": "Anything you publish to Nostr is public and permanent",
"privacy.permanent.p1": "This is the part worth reading twice. A review is a signed event on public relays. Once it is out, it is out:",
"privacy.permanent.li1": "Anyone can read it, now and later, using any Nostr client. It is not \"public on Cashumints.space\", it is public on the network.",
"privacy.permanent.li2": "It is permanently linked to your Nostr public key, alongside everything else that key has ever published.",
"privacy.permanent.li3": "Deleting it is a request, not a command. You can publish a deletion event and some relays will honour it, but a relay is free to ignore it and anyone who already copied the event still has it. Treat a published review as permanent.",
"privacy.permanent.li4": "We cannot delete it for you. We can stop displaying it here; that changes nothing on the relays.",
"privacy.permanent.p2": "If you want to review a mint without that being tied to your main identity, use a separate Nostr key. Note that a key with no other activity is shown as such on harsh reviews, because a brand new key writing a one star review is a pattern readers deserve to see.",
"privacy.contact.title": "Questions",
"privacy.contact.p1": "The site is open source: if you want to check any of the above, read the code at {github}. Questions and corrections can go in an issue there.",
"disclaimer.title": "Disclaimer · Cashumints.space",
"disclaimer.description": "What to know before you use a Cashu mint: mints are custodial, a mint can disappear with your money, reviews are opinions and online means reachable, not trustworthy.",
"disclaimer.heading": "Disclaimer",
"disclaimer.lede": "Read this before you put money in a mint. Last updated {date}.",
"disclaimer.callout": "A Cashu mint holds your money for you. If it disappears, your ecash is gone, and there is nobody to appeal to. Keep only what you can afford to lose.",
"disclaimer.custodial.title": "Mints are custodial",
"disclaimer.custodial.p1": "When you mint ecash, you hand bitcoin to the mint operator and get tokens back. Those tokens are a claim on that operator. They are not bitcoin you hold, and the operator is the one who decides whether to honour them.",
"disclaimer.custodial.p2": "That means every mint carries the same risks: the operator can vanish, can be compelled to stop, can lose the funds through a bug or a hack, or can simply turn the server off. There is no deposit insurance, no chargeback, no support line and no legal recourse worth counting on. This is true of the highest rated mint on this site.",
"disclaimer.custodial.p3": "Cashu is designed for small amounts moving quickly. Use it that way: hold what you are about to spend, not your savings, and spread across mints rather than parking everything in one.",
"disclaimer.reviews.title": "Reviews are opinions, not verified claims",
"disclaimer.reviews.p1": "Every review here was written by someone on Nostr and signed with their key. Signed means we can tell you which key wrote it. It does not mean the review is true, that the author used the mint, or that the author is not the operator, a competitor, or one person with ten keys.",
"disclaimer.reviews.p2": "We do not verify reviewers, and we do not edit or remove reviews we disagree with. Read a few, look at what they actually say, and weigh a mint with three reviews differently from one with three hundred.",
"disclaimer.status.title": "Online means reachable, not trustworthy",
"disclaimer.status.p1": "We check every mint regularly and show whether it answered. That is all a green status means: the server responded. It says nothing about whether the operator is solvent, honest, or still intends to honour your tokens tomorrow.",
"disclaimer.status.p2": "A mint can be online right up to the moment it is not. Uptime history, supported NUTs and software version tell you something about how a mint is run, but none of them are a promise about your money.",
"disclaimer.ranking.title": "How ranking works, and what it does not mean",
"disclaimer.ranking.p1": "Mints are ordered by an algorithm over public review data: rating, number of reviews and recent activity, with online mints ahead of offline ones by default. The details are in the open source code.",
"disclaimer.ranking.p2": "Nobody pays for placement. There is no sponsored slot, no promoted listing and no arrangement with any mint on this site. A high rank means the algorithm liked the public data, and nothing more. It is not a recommendation and it is not a safety rating.",
"disclaimer.notAdvice.title": "This is not financial advice",
"disclaimer.notAdvice.p1": "Nothing on this site is financial, investment, legal or tax advice. We do not know your situation and we are not advising you. Using a mint, and choosing which one, is your decision.",
"disclaimer.dyor.title": "Do your own research",
"disclaimer.dyor.li1": "Look up the operator. A mint run by someone with a public reputation to lose is a different proposition from an anonymous URL.",
"disclaimer.dyor.li2": "Read the mint's own terms and its MOTD. A mint that calls itself a test or a beta means it.",
"disclaimer.dyor.li3": "Start small. Mint a little, spend it, melt it back out, and see what happens before you trust it with more.",
"disclaimer.dyor.li4": "Watch the limits. A low maximum is often a sign of a careful operator, and a high one is not a sign of a safe one.",
"disclaimer.dyor.li5": "Keep your tokens backed up, and remember that a token is a bearer asset: whoever holds it can spend it.",
"disclaimer.site.title": "About the site itself",
"disclaimer.site.p1": "Data here can be wrong or out of date. Mints are probed on a schedule, relays sometimes fail to answer, and a page you are reading was built at some point in the past. The site is provided as it is, with no warranty. See the {link}.",
"disclaimer.site.link": "terms of use",
"disclaimer.updated": "20 August 2026",
"login.getPrimal": "Get Primal",
"reviews.dialog.identity.extensionGone": "Your browser extension is not answering. Log in again, or use a remote signer.",
"seo.orgDescription": "A directory of Cashu mints, with live status from each mint and community reviews signed on Nostr.",
"seo.ogImageAlt": "Cashumints.space: find a Cashu mint you can trust.",
"nav.home": "Home",
"mint.description.fallbackDomain": "A Cashu mint running at {domain}.",
"mint.more.title": "More mints",
"mint.more.all": "See all mints"
}
+554
View File
@@ -0,0 +1,554 @@
{
"site.name": "Cashumints.space",
"common.close": "Cerrar",
"common.cancel": "Cancelar",
"common.done": "Listo",
"common.back": "Atrás",
"common.retry": "Reintentar",
"common.connect": "Conectar",
"common.copied": "Copiado",
"common.copyFull": "Copiar {kind} completo",
"common.previousPage": "Página anterior",
"common.nextPage": "Página siguiente",
"common.reviewPages": "Páginas de reseñas",
"time.never": "nunca",
"time.justNow": "ahora mismo",
"time.none": "-",
"time.unknownDate": "una fecha desconocida",
"time.ago.minute": "hace {n} min",
"time.ago.hour": "hace {n} h",
"time.ago.day": "hace {n} d",
"time.ago.week": "hace {n} sem",
"time.ago.month.one": "hace {n} mes",
"time.ago.month.other": "hace {n} meses",
"time.ago.year.one": "hace {n} año",
"time.ago.year.other": "hace {n} años",
"time.short.minute": "{n} min",
"time.short.hour": "{n} h",
"time.short.day": "{n} d",
"time.short.week": "{n} sem",
"time.short.month.one": "{n} mes",
"time.short.month.other": "{n} meses",
"time.short.year.one": "{n} año",
"time.short.year.other": "{n} años",
"status.online": "En línea",
"status.offline": "Sin conexión",
"status.degraded": "No responde",
"status.unknown": "Sin comprobar aún",
"nav.skip": "Saltar al contenido",
"nav.main": "Principal",
"nav.mints": "Mints",
"nav.reviews": "Reseñas",
"nav.wallets": "Monederos",
"nav.about": "Acerca de",
"nav.searchMints": "Buscar en {n} mints",
"lang.label": "Idioma",
"lang.switcher": "Elige un idioma",
"footer.copyright": "© {year} Cashumints.space",
"footer.allMints": "Todos los mints",
"footer.allReviews": "Todas las reseñas",
"footer.wallets": "Monederos",
"footer.about": "Acerca de",
"footer.disclaimer": "Aviso legal",
"footer.terms": "Términos",
"footer.privacy": "Privacidad",
"footer.madeBy": "Hecho con ❤️ por {azzamo} · Funciona con Cashu y NIP-87",
"footer.warning": "Los mints son custodiales. Un mint puede desaparecer con tu dinero.",
"home.title": "Cashumints.space · Encuentra un mint Cashu en el que confiar",
"home.description": "Encuentra un mint Cashu en el que confiar. {mints} mints indexados con {reviews} reseñas de la comunidad firmadas en Nostr, además de disponibilidad en directo, NUTs compatibles y software del mint.",
"home.hero.title": "Encuentra un mint Cashu",
"home.hero.titleEm": "en el que confiar",
"home.hero.lede": "Todos los mints de la red Nostr, con reseñas reales de la comunidad. Sin intermediarios, solo reseñas firmadas que puedes verificar tú mismo.",
"home.search.label": "Buscar mints por nombre, dominio o URL",
"home.search.placeholder": "Busca por nombre, dominio, o pega la URL de un mint…",
"home.filters.mostReviewed": "Más reseñados",
"home.filters.highestRated": "Mejor valorados",
"home.filters.recentlyActive": "Activos hace poco",
"home.filters.websockets": "Compatibles con WebSockets",
"home.top.title": "Mejores mints",
"home.top.sub": "ordenados por reseñas y valoración",
"home.top.all": "Ver los {n} mints →",
"home.latest.title": "Últimas reseñas",
"home.latest.sub": "en directo desde la red Nostr",
"home.latest.prev": "Reseñas anteriores",
"home.latest.next": "Más reseñas",
"home.latest.all": "Ver todas las reseñas →",
"home.latest.empty": "Ahora mismo no se ha podido leer ninguna reseña de los relays. Abre la página de cualquier mint para leer y escribir reseñas directamente.",
"home.why.title": "¿Por qué fiarte de estas reseñas?",
"home.why.signed.title": "Firmadas, no enviadas",
"home.why.signed.body": "Cada reseña es un evento {link} firmado con la clave Nostr de quien la escribió. Aquí no editamos, ordenamos a mano ni borramos nada.",
"home.why.signed.link": "NIP-87",
"home.why.verify.title": "Compruébalo tú mismo",
"home.why.verify.body": "Las reseñas viven en relays públicos de Nostr, no en nuestra base de datos. Apunta cualquier cliente a esos mismos relays y verás los mismos datos.",
"home.why.checked.title": "Comprobados continuamente",
"home.why.checked.body": "Comprobamos cada mint para saber su disponibilidad, versión del software, límites y NUTs compatibles, así que lo que ves está al día y no lo dice el propio mint.",
"home.cta.title": "¿Estás pensando en un mint? Léelo primero.",
"home.cta.body": "Un mint guarda tu ecash. Lee lo que ha encontrado otra gente antes de meter sats, y deja una reseña cuando hayas usado uno tú.",
"home.cta.explore": "Explorar todos los mints",
"home.cta.learn": "Aprender sobre Cashu",
"pulse.mintsIndexed": "mints indexados",
"pulse.reviewsOnNostr": "reseñas en Nostr",
"pulse.lastReview": "última reseña",
"pulse.mintsOnline": "mints en línea",
"pulse.indexer": "indexador",
"pulse.connected": "conectado",
"pulse.stale": "desactualizado",
"mints.title": "Todos los mints Cashu · Cashumints.space",
"mints.description": "Los {total} mints Cashu indexados desde la red Nostr, {online} en línea y {offline} sin conexión, con valoraciones de la comunidad, disponibilidad y software del mint. Busca y ordena la lista completa.",
"mints.heading": "Todos los mints",
"mints.lede": "Todos los mints descubiertos en la red Nostr, incluidos los que ya no están. Los mints sin conexión siguen en la lista para que puedas encontrarlos y reseñarlos.",
"mints.search.label": "Buscar mints por nombre o dominio",
"mints.search.placeholder": "Busca por nombre o dominio…",
"mints.sort.label": "Ordenar mints",
"mints.sort.score": "Mejores en general",
"mints.sort.rating": "Mejor valorados",
"mints.sort.reviews": "Más reseñados",
"mints.sort.recent": "Reseñados hace poco",
"mints.sort.seen": "Vistos en línea hace poco",
"mints.hideOffline": "Ocultar sin conexión",
"mints.showing": "Mostrando {total} mints ({online} en línea, {offline} sin conexión)",
"mints.showingFiltered": "Mostrando {shown} de {total} mints ({online} en línea, {offline} sin conexión)",
"mints.noResults": "Ningún mint coincide. Prueba con un dominio, o pega la URL de un mint.",
"mints.clearSearch": "Borrar la búsqueda",
"card.noRatings": "Sin valoraciones aún",
"card.reviews.one": "{n} reseña",
"card.reviews.other": "{n} reseñas",
"card.lastSeen": "visto {when}",
"card.neverSeen": "nunca visto en línea",
"card.reviewed": "reseñado {when}",
"card.noReviews": "sin reseñas aún",
"mint.title": "{name} · Reseñas del mint Cashu",
"mint.description.fallback": "{name} es un mint Cashu en {domain}.",
"mint.description.rated.one": "Valorado con {rating} sobre 5 a partir de {n} reseña firmada en Nostr.",
"mint.description.rated.other": "Valorado con {rating} sobre 5 a partir de {n} reseñas firmadas en Nostr.",
"mint.description.noReviews": "Todavía no hay reseñas de la comunidad.",
"mint.description.online": "Ahora mismo en línea.",
"mint.description.unreachable": "Ahora mismo no responde.",
"mint.breadcrumb": "Ruta de navegación",
"mint.crumbMints": "Mints",
"mint.copyUrl": "Copiar la URL del mint",
"mint.qrButton": "QR del mint",
"mint.writeReview": "Escribir una reseña",
"mint.rankByScore": "#{rank} por puntuación",
"mint.tagLightning": "Lightning",
"mint.summary": "Resumen del mint",
"mint.rating.label": "Valoración de la comunidad",
"mint.rating.none": "Sin valoraciones aún",
"mint.rating.reviews.one": "{n} reseña",
"mint.rating.reviews.other": "{n} reseñas",
"mint.rating.unrated.one": "{n} no dejó valoración",
"mint.rating.unrated.other": "{n} no dejaron valoración",
"mint.activity.label": "Actividad",
"mint.activity.since": "desde la última reseña",
"mint.activity.noReviews": "sin reseñas aún",
"mint.activity.recent.one": "{n} reseña en los últimos 90 días",
"mint.activity.recent.other": "{n} reseñas en los últimos 90 días",
"mint.limits.label": "Límites",
"mint.limits.notPublished": "sin publicar",
"mint.limits.min": "mín",
"mint.limits.max": "máx",
"mint.limits.mintRow": "acuñar",
"mint.limits.meltRow": "fundir",
"mint.limits.disabled": "desactivado",
"mint.limits.range": "de {min} a {max} {unit}",
"mint.limits.upTo": "hasta {max} {unit}",
"mint.limits.from": "desde {min} {unit}",
"mint.software.label": "Software del mint",
"mint.software.unknown": "desconocido",
"mint.checked": "comprobado {when}",
"mint.lastSeen": "visto {when}",
"mint.neverReached": "nunca alcanzado",
"mint.uptime.checks": "{ok} de las últimas {total} comprobaciones respondieron",
"mint.uptime.percent": "{percent}% de disponibilidad en 30 días",
"mint.uptime.description": "{ok} de las últimas {total} comprobaciones salieron bien",
"mint.operator.title": "De quien lo opera",
"mint.operator.motd": "Mensaje del día",
"mint.operator.beta": "Este mint se describe a sí mismo como experimental o en beta. Lee abajo lo que dice quien lo opera antes de guardar ahí nada que no puedas perder.",
"mint.nuts.title": "NUTs compatibles",
"mint.nuts.noneOffline": "No hay lista de NUTs guardada para este mint, se quedó sin conexión antes de que se registrara una.",
"mint.nuts.noneYet": "Todavía no hay lista de NUTs guardada para este mint.",
"mint.nuts.supported": "{n} de {total} NUTs conocidos son compatibles",
"mint.nuts.whatAre": "¿qué son los NUTs?",
"mint.nutRow.disabled": "desactivado",
"mint.nutRow.notSupported": "no compatible",
"mint.details.title": "Datos del mint",
"mint.details.pubkey": "Pubkey",
"mint.details.nostr": "Nostr",
"mint.details.email": "Correo",
"mint.details.serverTime": "Hora del servidor",
"mint.details.firstSeen": "Visto por primera vez",
"mint.details.terms": "Términos",
"mint.kind.identifier": "identificador",
"mint.kind.mintPubkey": "pubkey del mint",
"mint.kind.nostrAddress": "dirección Nostr",
"mint.kind.emailAddress": "dirección de correo",
"mint.kind.npub": "npub",
"mint.disclaimer": "Este mint es custodial: guarda tu dinero, y puede dejar de hacerlo en cualquier momento. Las valoraciones son opiniones de la comunidad, y en línea significa accesible, no fiable.",
"mint.disclaimerLink": "Leer el aviso legal completo",
"mint.qr.title": "Añade {name} a tu monedero",
"mint.qr.note": "Escanea esto con un monedero Cashu para añadir el mint, o copia la URL y pégala ahí.",
"mint.qr.warn": "Añadir un mint significa confiarle tu dinero.",
"mint.qr.whatMeans": "Qué significa eso",
"mint.qr.wallets": "Monederos compatibles con Cashu",
"mint.chip.frozen": "Congelado",
"mint.chip.noWithdrawals": "Sin retiradas",
"mint.chip.meltOnly": "Solo fundir",
"mint.warnings.dayCount.one": "{n} día",
"mint.warnings.dayCount.other": "{n} días",
"mint.warnings.gone.lead": "Probablemente ha desaparecido.",
"mint.warnings.gone.body.since": "Este mint lleva sin responder desde el {date} ({days}). Da por perdido el dinero que tengas ahí y no envíes más. Los datos de abajo son su último estado conocido.",
"mint.warnings.gone.body.neverDated": "Este mint no ha respondido a ni una sola comprobación desde que empezamos a vigilarlo en {month}. Da por perdido el dinero que tengas ahí y no envíes más. Todo lo de abajo viene del anuncio en Nostr, no del propio mint.",
"mint.warnings.gone.body.never": "Este mint no ha respondido a ni una sola comprobación desde que empezamos a vigilarlo. Da por perdido el dinero que tengas ahí y no envíes más. Todo lo de abajo viene del anuncio en Nostr, no del propio mint.",
"mint.warnings.gone.meta.since": "Sin conexión desde el {date}",
"mint.warnings.gone.meta.never": "Nunca ha respondido desde que se descubrió",
"mint.warnings.meltDisabled.lead": "Retiradas desactivadas.",
"mint.warnings.meltDisabled.body.online": "Este mint tiene desactivado el fundido por Lightning (melt, NUT-05): puedes meter sats, pero no puedes sacarlos por Lightning. No deposites hasta que el fundido vuelva a funcionar.",
"mint.warnings.meltDisabled.body.offline": "Este mint tenía desactivado el fundido por Lightning (melt, NUT-05): puedes meter sats, pero no puedes sacarlos por Lightning. No deposites hasta que el fundido vuelva a funcionar.",
"mint.warnings.meltDisabled.meta.online": "Retiradas desactivadas ahora mismo",
"mint.warnings.meltDisabled.meta.offline": "Las retiradas estaban desactivadas la última vez que respondió",
"mint.warnings.frozen.lead": "Mint congelado.",
"mint.warnings.frozen.body.online": "La acuñación y el fundido están los dos desactivados. No entra ni sale ni un sat por Lightning.",
"mint.warnings.frozen.body.offline": "La acuñación y el fundido estaban los dos desactivados. No entraba ni salía ni un sat por Lightning.",
"mint.warnings.frozen.meta.online": "Congelado ahora mismo, no entra ni sale nada",
"mint.warnings.frozen.meta.offline": "Estaba congelado la última vez que respondió",
"mint.warnings.offlineLong.lead": "Sin conexión desde hace {days}, desde el {date}.",
"mint.warnings.offlineLong.body": "Puede que el mint haya desaparecido. Todo lo de abajo es su último estado conocido, y las reseñas siguen funcionando.",
"mint.warnings.offlineLong.meta": "Sin conexión desde el {date}",
"mint.warnings.meltOnly.lead": "Solo fundir.",
"mint.warnings.meltOnly.body.online": "La acuñación de ecash nuevo (mint, NUT-04) está desactivada: no puedes depositar, pero el ecash que ya tienes todavía se puede retirar. Esto suele querer decir que el mint está cerrando, así que si tienes ecash suyo, plantéate fundirlo y sacarlo.",
"mint.warnings.meltOnly.body.offline": "La acuñación de ecash nuevo (mint, NUT-04) estaba desactivada: no puedes depositar, pero el ecash que ya tienes todavía se puede retirar. Esto suele querer decir que el mint está cerrando, así que si tienes ecash suyo, plantéate fundirlo y sacarlo.",
"mint.warnings.meltOnly.meta.online": "Ahora mismo solo se puede fundir",
"mint.warnings.meltOnly.meta.offline": "Solo se podía fundir la última vez que respondió",
"mint.warnings.offline.lead": "Sin conexión desde el {date}.",
"mint.warnings.offline.body": "Los datos se confirmaron por última vez entonces. Todavía puedes escribir una reseña.",
"mint.warnings.offline.meta": "Sin conexión desde el {date}",
"mint.warnings.also.meltDisabled": "Antes de quedarse sin conexión también había desactivado las retiradas.",
"mint.warnings.also.mintDisabled": "Antes de quedarse sin conexión también había desactivado la acuñación de ecash nuevo.",
"mint.warnings.also.offline": "Además lleva sin conexión desde el {date} ({days}).",
"nut.0": "Notación y modelos",
"nut.1": "Intercambio de la clave pública del mint",
"nut.2": "Conjuntos de claves y comisiones",
"nut.3": "Intercambio de tokens",
"nut.4": "Acuñación por Lightning",
"nut.5": "Fundido por Lightning",
"nut.6": "Información del mint",
"nut.7": "Comprobación del estado de un token",
"nut.8": "Comisiones de Lightning pagadas de más",
"nut.9": "Restaurar firmas",
"nut.10": "Condiciones de gasto",
"nut.11": "Pago a clave pública",
"nut.12": "Pruebas DLEQ",
"nut.13": "Secretos deterministas",
"nut.14": "Contratos con bloqueo temporal por hash",
"nut.15": "Pagos por varias rutas",
"nut.16": "Códigos QR animados",
"nut.17": "Suscripciones por WebSocket",
"nut.18": "Solicitudes de pago",
"nut.19": "Respuestas en caché",
"nut.20": "Cotizaciones de acuñación firmadas",
"nut.21": "Autenticación en claro",
"nut.22": "Autenticación ciega",
"nut.23": "Método de pago BOLT11",
"nut.24": "HTTP 402, pago requerido",
"nut.25": "Método de pago BOLT12",
"reviews.title": "Reseñas",
"reviews.anon": "Anón",
"reviews.filter.group": "Filtrar reseñas",
"reviews.filter.all": "Todas",
"reviews.filter.five": "5★",
"reviews.filter.critical": "Críticas",
"reviews.filter.recent": "Recientes",
"reviews.loading.one": "Cargando {n} reseña de los relays de Nostr",
"reviews.loading.other": "Cargando {n} reseñas de los relays de Nostr",
"reviews.empty": "Todavía no hay reseñas. Sé quien escriba la primera.",
"reviews.emptyWritten": "Todavía no hay reseñas escritas aquí. Sé quien escriba la primera.",
"reviews.emptyFilter": "Ninguna reseña coincide con este filtro.",
"reviews.noscript": "Las reseñas están firmadas en Nostr y se cargan en tu navegador, así que esta lista necesita JavaScript. La valoración y los recuentos de arriba vienen del propio sitio.",
"reviews.relayError": "No se ha podido llegar al relay. Puede que falten reseñas.",
"reviews.srRating": "{rating} sobre 5",
"reviews.srNoRating": "Sin valoración",
"reviews.anonNote": "Primera reseña de este npub, no se ha encontrado ninguna otra actividad",
"reviews.published": "Publicada.",
"reviews.publishedPartial": "Publicada en {accepted} de {total} relays.",
"reviews.propagating": "Propagándose, puede tardar un momento en aparecer en otros sitios.",
"reviews.summary.line.one": "{count} valoración sin comentario: {breakdown}",
"reviews.summary.line.other": "{count} valoraciones sin comentario: {breakdown}",
"reviews.summary.lineDay.one": "{count} valoración sin comentario del {when}: {breakdown}",
"reviews.summary.lineDay.other": "{count} valoraciones sin comentario del {when}: {breakdown}",
"reviews.summary.showOne": "Mostrarla",
"reviews.summary.showMany": "Mostrarlas",
"reviews.breakdown.5": "{n} de cinco estrellas",
"reviews.breakdown.4": "{n} de cuatro estrellas",
"reviews.breakdown.3": "{n} de tres estrellas",
"reviews.breakdown.2": "{n} de dos estrellas",
"reviews.breakdown.1": "{n} de una estrella",
"reviews.breakdown.none": "{n} sin valoración",
"reviews.dialog.title": "Reseñar {name}",
"reviews.dialog.note": "Se firma con tu clave Nostr y se publica en relays públicos. No se puede editar ni borrar.",
"reviews.dialog.ratingLegend": "Tu valoración",
"reviews.dialog.starsSr.one": "{n} estrella",
"reviews.dialog.starsSr.other": "{n} estrellas",
"reviews.dialog.chooseRating": "Elige una valoración",
"reviews.dialog.starLabel": "{n}: {phrase}",
"reviews.dialog.stars.1": "evítalo",
"reviews.dialog.stars.2": "tuve problemas",
"reviews.dialog.stars.3": "funcionó, con reparos",
"reviews.dialog.stars.4": "bien",
"reviews.dialog.stars.5": "funciona genial",
"reviews.dialog.bodyLabel": "¿Qué debería saber la gente?",
"reviews.dialog.bodyPlaceholder": "¿Qué tal fue acuñar, fundir y el soporte? Sé concreto.",
"reviews.dialog.charCount": "{n} / {max}",
"reviews.dialog.submit": "Firmar y publicar",
"reviews.dialog.signing": "Firmando…",
"reviews.dialog.publishing": "Publicando…",
"reviews.dialog.publishingTo": "Publicando en los relays ({accepted}/{total})",
"reviews.dialog.waitingExtension": "Esperando a tu extensión",
"reviews.dialog.waitingSigner": "Esperando a tu firmante",
"reviews.dialog.confirmClose": "¿Cerrar esta reseña? Tu borrador se guarda hasta que salgas del sitio.",
"reviews.dialog.why.login": "Inicia sesión para publicar",
"reviews.dialog.why.rating": "Elige una valoración",
"reviews.dialog.why.words": "Escribe unas palabras",
"reviews.dialog.why.more.one": "{n} carácter más",
"reviews.dialog.why.more.other": "{n} caracteres más",
"reviews.dialog.why.over.one": "{n} carácter de más",
"reviews.dialog.why.over.other": "{n} caracteres de más",
"reviews.dialog.identity.needSigner": "Necesitas un firmante de Nostr para publicar. Tu clave se queda en él, este sitio no la ve nunca.",
"reviews.dialog.identity.loginToPublish": "Inicia sesión para publicar",
"reviews.dialog.identity.switch": "Cambiar",
"reviews.dialog.identity.extension": "Firmando con la extensión de tu navegador",
"reviews.dialog.identity.readingKey": "Leyendo tu clave pública…",
"reviews.dialog.identity.willAsk": "Se le pedirá a tu extensión que firme",
"reviews.dialog.error.noRelay": "Ningún relay ha aceptado la reseña. Comprueba tu conexión y vuelve a intentarlo.",
"reviews.validate.rating": "Elige una valoración de 1 a 5 estrellas.",
"reviews.validate.short": "Escribe al menos {n} caracteres para que la reseña sirva de algo.",
"reviews.validate.long": "Las reseñas están limitadas a {n} caracteres.",
"feed.title": "Últimas reseñas de mints Cashu · Cashumints.space",
"feed.description": "Cada reseña de un mint Cashu según se publica: {reviews} reseñas de {mints} mints, firmadas en Nostr y legibles aquí de la más nueva a la más antigua. Filtra por valoración o por mint.",
"feed.heading": "Últimas reseñas de mints Cashu",
"feed.lede": "Todas las reseñas de todos los mints, de la más nueva a la más antigua, leídas en directo de los relays de Nostr. Cada una está firmada por quien la escribió y vive en los relays, no en nuestra base de datos.",
"feed.label": "Reseñas",
"feed.filterByMint": "Filtrar por mint",
"feed.anyMint": "Cualquier mint",
"feed.clearMint": "Borrar el filtro de mint",
"feed.showingBuild.one": "Mostrando la reseña más reciente de la última compilación",
"feed.showingBuild.other": "Mostrando las {n} reseñas más recientes de la última compilación",
"feed.noscript": "Las reseñas están firmadas en Nostr y se cargan en tu navegador, así que los filtros y la lista completa necesitan JavaScript. Las {n} reseñas de abajo se leyeron de los relays cuando se compiló esta página.",
"feed.emptyBuild": "No se pudo leer ninguna reseña de los relays cuando se compiló esta página. La lista se vuelve a cargar desde los relays en tu navegador.",
"feed.foot": "Las reseñas son opiniones de la comunidad, no hechos verificados. Lee el {link} antes de confiarle dinero a un mint.",
"feed.footLink": "aviso legal",
"feed.showing": "Mostrando {shown} de {total} reseñas",
"feed.showingMatching": "Mostrando {shown} de {matching} reseñas que coinciden ({total} en total)",
"feed.emptyRelays": "Los relays no han devuelto ninguna reseña. Abre la página de cualquier mint para leer y escribir reseñas ahí.",
"feed.emptyFilter": "Ninguna reseña coincide con este filtro. Borra la caja del mint, o prueba con Todas.",
"feed.relayError": "No se ha podido llegar a los relays. Mostrando las reseñas de la última compilación.",
"feed.relayEmpty": "Los relays no han devuelto ninguna reseña. Mostrando las reseñas de la última compilación.",
"feed.reading": "Leyendo los relays…",
"login.open": "Iniciar sesión",
"login.title": "Inicia sesión con Nostr",
"login.note": "Tu clave se queda en tu firmante. Este sitio no puede verla nunca.",
"login.extension.name": "Extensión del navegador",
"login.extension.desc": "Firma con Alby, nos2x u otra extensión NIP-07",
"login.extension.none": "No se ha encontrado ninguna extensión",
"login.extension.getOne": "Consigue una:",
"login.remote.name": "Firmante remoto",
"login.remote.desc": "Escanea un código con una app de firma, o pega un enlace bunker",
"login.primal.name": "Primal",
"login.primal.desc": "Aprueba el inicio de sesión en la app de Primal en tu móvil",
"login.paste.label": "O pega el enlace de conexión que te dio tu firmante",
"login.copyLink": "Copiar el enlace de conexión",
"login.whatIsNostr": "¿Qué es Nostr?",
"login.connect.remote.title": "Conectar un firmante remoto",
"login.connect.remote.lead": "Escanea esto con tu app de firma, o copia el enlace y pégalo ahí.",
"login.connect.remote.hint": "Funciona con cualquier firmante NIP-46: nsec.app, Amber, Nostrify, tu propio bunker.",
"login.connect.primal.title": "Conectar con Primal",
"login.connect.primal.lead": "Abre Primal en tu móvil, busca la firma remota en sus ajustes y escanea este código.",
"login.connect.primal.hint": "Primal firma para otras apps por NIP-46, así que esta es la misma conexión que usa cualquier firmante, y tu clave se queda en Primal. {link}",
"login.status.buildingLink": "Creando un enlace de conexión",
"login.status.waitingExtension": "Esperando a tu extensión",
"login.status.waitingSigner": "Esperando a tu firmante",
"login.status.connecting": "Conectando con tu firmante",
"login.status.connected": "Conectado",
"login.method.extension": "Extensión",
"login.method.remote": "Remoto",
"login.method.primal": "Primal",
"login.account": "Tu cuenta de Nostr",
"login.copyNpub": "Copiar tu npub completo",
"login.logout": "Cerrar sesión",
"signer.status.waitingExtension": "Esperando a tu extensión",
"signer.status.signed": "Firmado",
"signer.status.reconnecting": "Reconectando con tu firmante",
"signer.status.approveWindow": "Tu firmante necesita que lo apruebes en su propia ventana",
"signer.status.waitingSigner": "Esperando a tu firmante",
"signer.status.stillWaiting": "Seguimos esperando a que responda tu firmante",
"signer.status.connected": "Conectado",
"signer.error.noExtension": "No se ha encontrado ninguna extensión de firma. Instala Alby o nos2x y recarga esta página.",
"signer.error.privateKey": "Eso parece una clave privada, no un enlace de conexión. Este sitio nunca acepta una clave. Pega el enlace bunker:// que te dio tu firmante.",
"signer.error.badLink": "Ese no es un enlace de conexión que este sitio entienda. Pega el enlace bunker:// de tu firmante.",
"signer.error.noAnswer": "Tu firmante no ha respondido. Puede que esté cerrado, o que su relay esté caído. Ábrelo y vuelve a intentarlo.",
"signer.error.timeout": "Tu firmante no ha respondido a tiempo. Ábrelo, comprueba que sigue conectado y vuelve a intentarlo.",
"signer.error.cancelled": "Conexión cancelada.",
"signer.error.declined": "Se rechazó la petición de firma",
"notfound.title": "Página no encontrada · Cashumints.space",
"notfound.description": "Esa página no existe en Cashumints.space.",
"notfound.heading": "Esta página no existe",
"notfound.lede": "Puede que se haya movido, o que el mint todavía no esté indexado. Busca un mint por nombre o dominio, o pega la URL de un mint.",
"notfound.browse": "Ver todos los mints",
"notfound.home": "Ir al inicio",
"notfound.loading": "Cargando {host}",
"notfound.fb.rating": "Valoración",
"notfound.fb.reviews": "Reseñas",
"notfound.fb.software": "Software",
"notfound.fb.lastOnline": "Última vez en línea",
"notfound.fb.note": "Este mint se indexó después de la última compilación del sitio, así que esto es un resumen sacado de la API en directo. La página completa con reseñas aparecerá en la próxima compilación.",
"wallets.title": "Monederos Cashu · Cashumints.space",
"wallets.description": "Monederos Cashu que funcionan con los mints listados aquí, para móvil, web, navegador y línea de comandos, con lo que hace bien cada uno.",
"wallets.heading": "Monederos",
"wallets.lede": "Necesitas un monedero para guardar el ecash de cualquiera de estos mints. La mayoría hablan con varios mints a la vez, así que no te atas al primero que pruebes.",
"wallets.note": "Esta lista no está completa y no es una recomendación. El proyecto Cashu mantiene un índice más amplio en {link}. Un monedero guarda claves, un mint guarda el dinero: lee las reseñas de los mints antes de elegir dónde viven tus sats.",
"wallets.platform.android": "Android",
"wallets.platform.ios": "iOS",
"wallets.platform.web": "Web",
"wallets.platform.extension": "Extensión de navegador",
"wallets.platform.cli": "Línea de comandos",
"wallets.platform.python": "Python",
"wallets.item.minibits": "Monedero de ecash para móvil con su propio mint, direcciones Lightning y contactos de Nostr.",
"wallets.item.nutstash": "Monedero de navegador que maneja varios mints a la vez e importa y exporta tokens en crudo.",
"wallets.item.enuts": "Monedero móvil centrado en saldos repartidos entre mints y en enviar ecash por Nostr.",
"wallets.item.cashume": "El monedero web de referencia del proyecto Cashu. Funciona entero en el navegador y se puede instalar como PWA.",
"wallets.item.boardwalk": "Monedero web pensado para direcciones Lightning y para enviar ecash con un enlace.",
"wallets.item.coinos": "Monedero Lightning y TPV que además opera su propio mint Cashu.",
"wallets.item.oxchat": "Mensajería Nostr con un monedero de ecash integrado, para enviar sats dentro del chat.",
"wallets.item.nutshell": "La implementación de referencia. Funciona como monedero en línea de comandos y como software de mint.",
"about.title": "Acerca de Cashumints.space",
"about.description": "Qué es Cashu, qué guarda de verdad un mint, qué es NIP-87 y cómo este sitio indexa mints y lee reseñas de los relays de Nostr.",
"about.heading": "Acerca de",
"about.lede": "Cashumints.space lista todos los mints Cashu que encontramos en la red Nostr y te enseña lo que dice la gente sobre ellos. Lo hace Azzamo.",
"about.cashu.title": "Qué es Cashu",
"about.cashu.p1": "Cashu es un protocolo de ecash para Bitcoin. Un mint emite tokens ciegos respaldados por los sats que guarda, y esos tokens pasan de una persona a otra sin que el mint sepa quién le envió qué a quién. La privacidad es real y es el objetivo: los tokens no son cuentas, y el mint no puede enlazar el token que firmó con el token que le vuelve.",
"about.cashu.p2": "Puedes leer el protocolo y sus especificaciones en {link}. Las especificaciones se llaman NUTs, y de ahí salen los números NUT que ves en cada página de mint.",
"about.mints.title": "Qué es un mint, y qué estás cediendo",
"about.mints.p1": "Un mint es custodial. Cuando acuñas ecash le estás entregando sats a quien opere ese servidor, y confías en que respete los tokens cuando los fundas para sacarlos. No hay prueba de fraude, ni depósito en garantía, ni forma de reclamar. Quien opera un mint puede desaparecer con el saldo, y ha pasado.",
"about.mints.p2": "Justo por eso existen aquí las reseñas. Este sitio nunca te dice que un mint sea seguro. El estado de la página de un mint dice {code}, no \"de fiar\", y solo significa que el servidor respondió la última vez que preguntamos. Trata los saldos de ecash como el dinero suelto de un bolsillo: bien para gastar, mal para ahorrar.",
"about.nip87.title": "Qué es NIP-87",
"about.nip87.p1": "NIP-87 es la convención de Nostr para anunciar y recomendar mints de ecash. Aquí importan dos tipos de evento:",
"about.nip87.li1": "{code} anuncia un mint. La URL del mint va en la etiqueta {tag}. Así es como el sitio descubre mints que no ha visto nunca.",
"about.nip87.li2": "{code} es una reseña. Lleva la valoración y el texto, y apunta al mint del que habla con una etiqueta {utag} (la URL del mint) y una etiqueta {dtag} (la clave pública del propio mint).",
"about.nip87.p2": "Cada reseña está firmada con la clave Nostr de quien la escribió. Nunca decimos que una reseña esté \"verificada\", porque nadie la ha verificado: está firmada, que es otra cosa y más útil. Significa que puedes comprobar quién la escribió y que no se ha alterado. No significa que quien la escribió sea honesto ni que haya usado el mint.",
"about.how.title": "Cómo funciona este sitio",
"about.how.p1": "Hay dos bucles y una regla. La regla es que el mint es dueño de sus metadatos y Nostr es dueño de las reseñas, así que este sitio no guarda ninguna de las dos cosas como autoridad.",
"about.how.li1": "{strong} le pide {code} a cada mint conocido cada diez minutos y anota lo que dice: nombre, descripción, versión, NUTs compatibles, límites, datos de contacto. También anota si el mint respondió, y de ahí sale la disponibilidad.",
"about.how.li1Label": "El bucle de comprobación",
"about.how.li2": "{strong} se conecta a los relays de Nostr cada hora, lee anuncios de mints y reseñas, y añade cualquier mint que no haya visto antes.",
"about.how.li2Label": "El bucle de descubrimiento",
"about.how.p2": "Lo importante es qué pasa cuando un mint deja de responder. Sus metadatos guardados nunca se sobrescriben con una comprobación fallida, así que un mint muerto conserva su página entera: sus especificaciones, su historial y sus reseñas, con un aviso que dice cuándo se apagó. La versión anterior de este sitio mostraba una página de error, lo que significaba que los mints sobre los que más falta hacía avisarse eran justo los que no se podían reseñar. Esa fue la razón principal para rehacerlo.",
"about.how.p3": "El texto de las reseñas no se guarda nunca de nuestro lado. Cuando abres la página de un mint, tu navegador habla directamente con los relays y lee las reseñas él mismo, y cuando escribes una la firma tu propia extensión de Nostr y va derecha a esos relays. Nosotros guardamos un recuento para poder ordenar y clasificar las páginas, nada más. Aquí no podemos editar ni borrar nada, incluidas las reseñas que preferiríamos no tener.",
"about.relays.title": "Relays que leemos",
"about.relays.p1": "Estos son los relays que usan tanto el indexador como tu navegador:",
"about.relays.p2": "Apunta cualquier cliente de Nostr a esos mismos relays y verás los mismos eventos. Esa es toda la promesa: aquí nada es cierto solo porque lo digamos nosotros.",
"about.nuts.title": "¿Qué son los NUTs?",
"about.nuts.p1": "Los NUTs son las especificaciones numeradas de Cashu, como los BIPs de Bitcoin. Un mint anuncia cuáles implementa, y eso te dice lo que puede hacer de verdad: NUT-04 y NUT-05 son acuñar y fundir por Lightning (sin ellos un mint no sirve de mucho), NUT-07 deja que un monedero compruebe si un token ya se gastó, NUT-17 da a los monederos avisos en directo por WebSockets en vez de preguntar una y otra vez, y NUT-20 deja que un monedero demuestre que una cotización de acuñación se le emitió a él. La lista completa está en {link}.",
"about.numbers.title": "Ahora mismo",
"about.numbers.p1": "{mints} mints indexados, {online} de ellos respondiendo, {reviews} reseñas contadas de los relays de arriba.",
"about.source.title": "Código y créditos",
"about.source.p1": "El sitio es de código abierto en {github}. Lo hace y lo mantiene {azzamo}. Si la página de un mint tiene algo mal, arréglalo en el origen: los metadatos del mint vienen de su propio {code}, y las reseñas vienen de Nostr.",
"terms.title": "Términos de uso · Cashumints.space",
"terms.description": "Términos de uso de Cashumints.space: qué es el sitio, qué no hace y quién responde de qué. Sin cuentas, sin garantías, sin vínculo con ningún mint.",
"terms.heading": "Términos de uso",
"terms.lede": "Términos sencillos para un sitio que solo indexa datos públicos. Última actualización: {date}.",
"terms.what.title": "Qué es este sitio",
"terms.what.p1": "Cashumints.space es un índice de datos públicos sobre mints Cashu. Dos tipos de datos, los dos de fuera:",
"terms.what.li1": "Metadatos del mint, leídos del endpoint {code} de cada mint: nombre, descripción, versión del software, NUTs compatibles, datos de contacto y si el mint respondió la última vez que preguntamos.",
"terms.what.li2": "Reseñas, leídas de relays públicos de Nostr. Son eventos NIP-87 que sus autores publican en relays que cualquiera puede consultar.",
"terms.what.p2": "Recogemos esos datos, los normalizamos, los ordenamos y los mostramos. No los creamos.",
"terms.noAccounts.title": "Sin cuentas, sin garantías",
"terms.noAccounts.p1": "No hay nada a lo que registrarse. El sitio se ofrece tal cual, sin garantía de ningún tipo: ni de que los datos sean exactos, ni de que estén al día, ni de que el sitio vaya a estar disponible. Los datos de un mint pueden tener minutos u horas, un mint puede cambiar después de nuestra última comprobación, y un relay puede no responder, y entonces estás viendo menos de lo que hay.",
"terms.noAccounts.p2": "Úsalo bajo tu responsabilidad. No respondemos de ninguna pérdida derivada de usar este sitio o de fiarte de lo que leas aquí. Consulta el {link} para los riesgos concretos de usar un mint Cashu.",
"terms.noAccounts.link": "aviso legal",
"terms.noCustody.title": "No guardamos tu dinero",
"terms.noCustody.p1": "Este sitio nunca tiene la custodia de ningún fondo. No tiene monedero, no toca ecash, y no puede mover, congelar ni recuperar nada. Si tienes un problema con un mint, quien lo opera es la única parte que puede ayudarte, y nosotros no podemos contactar con esa persona en tu nombre.",
"terms.noCustody.p2": "No estamos vinculados a ningún mint de los listados, y ningún mint paga por aparecer, por su posición ni por su valoración. Los mints se descubren desde anuncios públicos de Nostr y se ordenan con un algoritmo sobre datos públicos de reseñas.",
"terms.reviews.title": "Las reseñas son palabras de otra gente",
"terms.reviews.p1": "Las reseñas son contenido de usuarios publicado en relays públicos de Nostr por sus autores. Las mostramos, no las controlamos. No las escribimos, no podemos editarlas, y no podemos borrarlas de los relays que las guardan: son eventos firmados en una red que no es nuestra.",
"terms.reviews.p2": "Una reseña es la experiencia de una persona, no un hecho verificado. Si publicas una, respondes de lo que diga. No publiques nada ilegal, y ten claro que lo que publicas es público y permanente (consulta la {link}).",
"terms.reviews.link": "página de privacidad",
"terms.reviews.p3": "Podemos dejar de mostrar una reseña concreta en este sitio, y lo haremos con contenido ilegal. Eso la quita solo de estas páginas. El evento sigue en los relays y cualquier otro cliente de Nostr puede seguir leyéndolo.",
"terms.links.title": "Enlaces a otros sitios",
"terms.links.p1": "Las URLs de los mints, los enlaces a monederos, los contactos de quienes los operan y todo lo demás que apunta fuera de este sitio está ahí porque forma parte de los datos. Un enlace no es una recomendación y no revisamos lo que hay al otro lado.",
"terms.changes.title": "Cambios",
"terms.changes.p1": "Estos términos pueden cambiar. La fecha de arriba dice cuándo lo hicieron por última vez. El sitio es de código abierto, así que cada cambio de esta página está en el historial de commits.",
"terms.contact.title": "Contacto",
"terms.contact.p1": "Las incidencias y las peticiones de retirada se presentan mejor en {github}. El sitio lo mantiene {azzamo}.",
"privacy.title": "Privacidad · Cashumints.space",
"privacy.description": "Privacidad en Cashumints.space: sin cuentas, sin cookies de seguimiento, sin analítica. localStorage se usa para guardar perfiles en caché y no se envía nada a ningún sitio. Las reseñas se firman en tu propio navegador.",
"privacy.heading": "Privacidad",
"privacy.lede": "Recogemos lo menos que puede recoger una web. Última actualización: {date}.",
"privacy.noAccounts.title": "Sin cuentas",
"privacy.noAccounts.p1": "No hay registro, ni inicio de sesión, ni perfil. Nada en este sitio te pregunta quién eres, y no hay ningún registro de usuario que se pueda filtrar porque no hay usuarios.",
"privacy.noTracking.title": "Sin cookies de seguimiento, sin analítica",
"privacy.noTracking.p1": "El sitio no pone ninguna cookie de seguimiento. No hay script de analítica, ni gestor de etiquetas, ni píxel, ni grabación de sesión, ni red publicitaria. Nada en la página informa de lo que haces, ni a nosotros ni a nadie.",
"privacy.logs.title": "Registros del servidor",
"privacy.logs.p1": "El servidor web guarda registros de acceso normales, que es lo que hace cualquier servidor web por defecto. Una línea de registro anota tu dirección IP, la hora, la página que pediste, el estado de la respuesta, y el agente de usuario y el referente que envió tu navegador. Los registros existen para depurar errores y detectar abusos. No se cruzan con nada más y no se venden ni se comparten.",
"privacy.logs.operatorLabel": "Nota para quien opere el sitio:",
"privacy.logs.operator": "si tú lo operas, indica aquí tu retención real. El despliegue del README usa nginx con su registro de acceso por defecto y la rotación que traiga tu distribución (en Debian y Ubuntu es logrotate, a diario, guardando 14 días). Confirma lo que hace tu servidor y sustituye este párrafo por el número.",
"privacy.storage.title": "Qué se guarda en tu navegador",
"privacy.storage.p1": "El sitio usa localStorage para exactamente dos cosas, y las dos se quedan en tu dispositivo y no se envían a ningún sitio:",
"privacy.storage.li1": "Una caché de metadatos de perfiles de Nostr (eventos de tipo 0: nombres, avatares, direcciones NIP-05) de quienes escribieron las reseñas que has mirado. Así no hay que preguntar a los relays en cada página. Las entradas caducan, y la caché tiene un tope y se vacía por orden de antigüedad.",
"privacy.storage.li2": "Preferencias de la interfaz, para que el sitio se comporte igual la próxima vez.",
"privacy.storage.p2": "Borrar los datos del sitio en tu navegador quita las dos cosas. No se rompe nada, simplemente se vuelven a buscar los perfiles.",
"privacy.thirdParties.title": "Con qué habla tu navegador",
"privacy.thirdParties.p1": "Cargar una página conecta tu navegador con unos cuantos sitios además de este. Así es como una web sin seguimiento en el servidor sigue leyendo datos en directo:",
"privacy.thirdParties.li1Label": "Relays públicos de Nostr",
"privacy.thirdParties.li1": "{strong}, por WebSocket, para leer reseñas y perfiles. El relay ve tu IP y lo que pediste, como cualquier servidor al que te conectes. Los relays están listados en la {link}.",
"privacy.thirdParties.li1Link": "página Acerca de",
"privacy.thirdParties.li2Label": "Imágenes de avatar",
"privacy.thirdParties.li2": "{strong}, cargadas desde donde apunte el perfil de quien escribió la reseña. Se piden con una política de no enviar referente, así que el servidor no sabe en qué página estabas.",
"privacy.publishing.title": "Publicar una reseña",
"privacy.publishing.p1": "Escribir una reseña ocurre en tu navegador, no en nuestro servidor. El evento lo firma tu extensión NIP-07 (Alby, nos2x o similar) y se emite desde tu navegador directamente a los relays. Tu clave privada no sale nunca de la extensión y este sitio no la ve. No hacemos gestión de claves propia y nunca pedimos una clave ni una frase semilla.",
"privacy.permanent.title": "Todo lo que publiques en Nostr es público y permanente",
"privacy.permanent.p1": "Esta es la parte que merece leerse dos veces. Una reseña es un evento firmado en relays públicos. Una vez fuera, está fuera:",
"privacy.permanent.li1": "Cualquiera puede leerla, ahora y más adelante, con cualquier cliente de Nostr. No es \"pública en Cashumints.space\", es pública en la red.",
"privacy.permanent.li2": "Queda ligada para siempre a tu clave pública de Nostr, junto a todo lo demás que haya publicado esa clave.",
"privacy.permanent.li3": "Borrarla es una petición, no una orden. Puedes publicar un evento de borrado y algunos relays lo respetarán, pero un relay es libre de ignorarlo y quien ya copiara el evento lo sigue teniendo. Da una reseña publicada por permanente.",
"privacy.permanent.li4": "No podemos borrarla por ti. Podemos dejar de mostrarla aquí, y eso no cambia nada en los relays.",
"privacy.permanent.p2": "Si quieres reseñar un mint sin que quede ligado a tu identidad principal, usa una clave Nostr aparte. Ten en cuenta que una clave sin ninguna otra actividad se señala como tal en las reseñas duras, porque una clave recién hecha escribiendo una reseña de una estrella es un patrón que quien lee merece ver.",
"privacy.contact.title": "Preguntas",
"privacy.contact.p1": "El sitio es de código abierto: si quieres comprobar cualquier cosa de las de arriba, lee el código en {github}. Las preguntas y correcciones pueden ir en una incidencia ahí.",
"disclaimer.title": "Aviso legal · Cashumints.space",
"disclaimer.description": "Lo que hay que saber antes de usar un mint Cashu: los mints son custodiales, un mint puede desaparecer con tu dinero, las reseñas son opiniones y en línea significa accesible, no fiable.",
"disclaimer.heading": "Aviso legal",
"disclaimer.lede": "Lee esto antes de meter dinero en un mint. Última actualización: {date}.",
"disclaimer.callout": "Un mint Cashu guarda tu dinero por ti. Si desaparece, tu ecash se va con él, y no hay nadie a quien reclamar. Guarda solo lo que te puedas permitir perder.",
"disclaimer.custodial.title": "Los mints son custodiales",
"disclaimer.custodial.p1": "Cuando acuñas ecash, le entregas bitcoin a quien opera el mint y recibes tokens a cambio. Esos tokens son un derecho frente a esa persona. No son bitcoin que tengas tú, y es quien opera el mint quien decide si los respeta.",
"disclaimer.custodial.p2": "Eso quiere decir que todos los mints tienen los mismos riesgos: quien lo opera puede esfumarse, puede verse obligado a parar, puede perder los fondos por un fallo o un ataque, o puede simplemente apagar el servidor. No hay seguro de depósitos, ni devolución del cargo, ni soporte, ni vía legal con la que contar de verdad. Esto vale también para el mint mejor valorado de este sitio.",
"disclaimer.custodial.p3": "Cashu está pensado para cantidades pequeñas que se mueven rápido. Úsalo así: guarda lo que estás a punto de gastar, no tus ahorros, y repártelo entre varios mints en vez de dejarlo todo en uno.",
"disclaimer.reviews.title": "Las reseñas son opiniones, no hechos verificados",
"disclaimer.reviews.p1": "Cada reseña de aquí la escribió alguien en Nostr y la firmó con su clave. Firmada quiere decir que podemos decirte qué clave la escribió. No quiere decir que la reseña sea cierta, ni que quien la escribió usara el mint, ni que no sea quien lo opera, la competencia, o una sola persona con diez claves.",
"disclaimer.reviews.p2": "No verificamos a quien reseña, y no editamos ni quitamos reseñas con las que no estemos de acuerdo. Lee unas cuantas, fíjate en lo que dicen de verdad, y no midas igual un mint con tres reseñas y uno con trescientas.",
"disclaimer.status.title": "En línea significa accesible, no fiable",
"disclaimer.status.p1": "Comprobamos cada mint con regularidad y mostramos si respondió. Eso es todo lo que significa un estado en verde: el servidor contestó. No dice nada sobre si quien lo opera es solvente, honesto, o si mañana seguirá respetando tus tokens.",
"disclaimer.status.p2": "Un mint puede estar en línea hasta el momento justo en que deja de estarlo. El historial de disponibilidad, los NUTs compatibles y la versión del software te dicen algo sobre cómo se lleva un mint, pero ninguno es una promesa sobre tu dinero.",
"disclaimer.ranking.title": "Cómo funciona el orden, y qué no significa",
"disclaimer.ranking.p1": "Los mints se ordenan con un algoritmo sobre datos públicos de reseñas: valoración, número de reseñas y actividad reciente, con los mints en línea por delante de los que no lo están. Los detalles están en el código abierto.",
"disclaimer.ranking.p2": "Nadie paga por su posición. No hay hueco patrocinado, ni listado promocionado, ni acuerdo con ningún mint de este sitio. Estar arriba significa que al algoritmo le gustaron los datos públicos, y nada más. No es una recomendación y no es una calificación de seguridad.",
"disclaimer.notAdvice.title": "Esto no es asesoramiento financiero",
"disclaimer.notAdvice.p1": "Nada de este sitio es asesoramiento financiero, de inversión, legal ni fiscal. No conocemos tu situación y no te estamos aconsejando. Usar un mint, y cuál, es decisión tuya.",
"disclaimer.dyor.title": "Infórmate por tu cuenta",
"disclaimer.dyor.li1": "Busca quién lo opera. Un mint llevado por alguien con una reputación pública que perder no es lo mismo que una URL anónima.",
"disclaimer.dyor.li2": "Lee los términos del propio mint y su mensaje del día. Un mint que se llama a sí mismo prueba o beta lo dice en serio.",
"disclaimer.dyor.li3": "Empieza con poco. Acuña un poco, gástalo, fúndelo para sacarlo, y mira qué pasa antes de confiarle más.",
"disclaimer.dyor.li4": "Fíjate en los límites. Un máximo bajo suele ser señal de que se opera con cuidado, y uno alto no es señal de que sea seguro.",
"disclaimer.dyor.li5": "Guarda una copia de tus tokens, y recuerda que un token es un activo al portador: quien lo tiene, lo puede gastar.",
"disclaimer.site.title": "Sobre el propio sitio",
"disclaimer.site.p1": "Los datos de aquí pueden estar mal o desactualizados. Los mints se comprueban según un horario, los relays a veces no responden, y la página que estás leyendo se compiló en algún momento del pasado. El sitio se ofrece tal cual, sin garantía. Consulta los {link}.",
"disclaimer.site.link": "términos de uso",
"disclaimer.updated": "20 de agosto de 2026",
"login.getPrimal": "Consigue Primal",
"reviews.dialog.identity.extensionGone": "La extensión de tu navegador no responde. Vuelve a iniciar sesión, o usa un firmante remoto.",
"seo.orgDescription": "Un directorio de mints Cashu, con el estado en vivo de cada mint y reseñas de la comunidad firmadas en Nostr.",
"seo.ogImageAlt": "Cashumints.space: encuentra un mint Cashu en el que puedas confiar.",
"nav.home": "Inicio",
"mint.description.fallbackDomain": "Un mint Cashu que funciona en {domain}.",
"mint.more.title": "Más mints",
"mint.more.all": "Ver todos los mints"
}
+175
View File
@@ -0,0 +1,175 @@
/**
* Locale-aware dates, times and numbers, for both halves of the site.
*
* One object per locale, built from that locale's translator, so a page and the island
* that updates it in place format the same timestamp the same way. Everything that used
* to be a hand-rolled `toLocaleDateString('en-GB', …)` goes through here now.
*
* This file ships to the browser, so it stays small: three cached Intl instances and
* some arithmetic.
*/
import { intlTag, type Locale } from './config.js';
import type { Translator } from './translate.js';
const MINUTE = 60;
const HOUR = 3600;
const DAY = 86_400;
const WEEK = 604_800;
const MONTH = 2_592_000;
const YEAR = 31_536_000;
/** Largest first: the first threshold a difference clears is the unit it is said in. */
const UNITS: Array<{ key: string; intl: Intl.RelativeTimeFormatUnit; seconds: number }> = [
{ key: 'year', intl: 'year', seconds: YEAR },
{ key: 'month', intl: 'month', seconds: MONTH },
{ key: 'week', intl: 'week', seconds: WEEK },
{ key: 'day', intl: 'day', seconds: DAY },
{ key: 'hour', intl: 'hour', seconds: HOUR },
{ key: 'minute', intl: 'minute', seconds: MINUTE },
];
const relativeFormats = new Map<string, Intl.RelativeTimeFormat>();
const dateFormats = new Map<string, Intl.DateTimeFormat>();
const numberFormats = new Map<string, Intl.NumberFormat>();
function relativeFormat(tag: string): Intl.RelativeTimeFormat {
let found = relativeFormats.get(tag);
if (!found) {
found = new Intl.RelativeTimeFormat(tag, { style: 'short', numeric: 'always' });
relativeFormats.set(tag, found);
}
return found;
}
function dateFormat(tag: string, options: Intl.DateTimeFormatOptions, id: string): Intl.DateTimeFormat {
const cacheKey = `${tag}:${id}`;
let found = dateFormats.get(cacheKey);
if (!found) {
found = new Intl.DateTimeFormat(tag, options);
dateFormats.set(cacheKey, found);
}
return found;
}
function numberFormat(tag: string): Intl.NumberFormat {
let found = numberFormats.get(tag);
if (!found) {
found = new Intl.NumberFormat(tag);
numberFormats.set(tag, found);
}
return found;
}
export interface Formatters {
locale: Locale;
t: Translator;
/** "4d ago", "hace 4 d", "4 d geleden". The dense form, used in rows and card feet. */
relative(unix: number | null, now?: number): string;
/** Magnitude only: "4d". The big number in the verdict strip. */
shortDuration(unix: number | null, now?: number): string;
/** "3 Aug 2026". Precise, for a banner where a fuzzy date is not good enough. */
date(unix: number | null): string;
/** "3 August 2026". The long form, for a day heading in the review feed. */
longDate(unix: number): string;
/** "Mar 2024", for "first seen". */
monthYear(unix: number): string;
/** "03/08/2026, 14:20 UTC". The mint's own clock, always in UTC. */
serverTime(unix: number): string;
/** Grouped: 1,000,000 in English, 1.000.000 in Spanish and Dutch. */
number(n: number): string;
/** A rating to one decimal, with the locale's own decimal separator. */
decimal(n: number, digits?: number): string;
}
/**
* Build the formatter set for one locale.
*
* Cheap enough to call per component: the Intl instances behind it are cached by tag,
* and the object itself is a handful of closures.
*/
export function formatters(t: Translator): Formatters {
const tag = intlTag(t.locale);
/**
* The relative form, catalog first.
*
* `Intl.RelativeTimeFormat` knows every language's grammar and is the right default,
* but its short forms are not short enough for this layout: English gives "4 days
* ago" where the design has room for "4d ago", and these strings sit in card feet and
* dense review rows that were measured against the compact form.
*
* So the catalog gets first refusal, per unit, and Intl is the fallback. A fourth
* language that never adds a single `time.ago.*` key still renders correct, natural
* relative times from day one; the three launch languages tune theirs for width.
*/
const relative = (unix: number | null, now = Date.now() / 1000): string => {
if (unix === null) return t('time.never');
const diff = Math.max(0, Math.floor(now - unix));
if (diff < MINUTE) return t('time.justNow');
for (const unit of UNITS) {
if (diff < unit.seconds) continue;
const n = Math.floor(diff / unit.seconds);
const key = `time.ago.${unit.key}`;
if (t.has(key)) return t(key, { n });
return relativeFormat(tag).format(-n, unit.intl);
}
return t('time.justNow');
};
const shortDuration = (unix: number | null, now = Date.now() / 1000): string => {
if (unix === null) return t('time.none');
const diff = Math.max(0, Math.floor(now - unix));
// Under a minute still reads as one minute here: this is a magnitude in a big
// display number, and "0m" says less than "1m".
if (diff < HOUR) return t('time.short.minute', { n: Math.max(1, Math.floor(diff / MINUTE)) });
for (const unit of UNITS) {
if (diff < unit.seconds) continue;
return t(`time.short.${unit.key}`, { n: Math.floor(diff / unit.seconds) });
}
return t('time.short.minute', { n: 1 });
};
return {
locale: t.locale,
t,
relative,
shortDuration,
date: (unix) =>
unix === null
? t('time.unknownDate')
: dateFormat(tag, { day: 'numeric', month: 'short', year: 'numeric' }, 'short').format(unix * 1000),
longDate: (unix) =>
dateFormat(tag, { day: 'numeric', month: 'long', year: 'numeric' }, 'long').format(unix * 1000),
monthYear: (unix) =>
dateFormat(tag, { month: 'short', year: 'numeric' }, 'monthYear').format(unix * 1000),
serverTime: (unix) =>
dateFormat(
tag,
{ dateStyle: 'short', timeStyle: 'short', timeZone: 'UTC' },
'serverTime',
).format(unix * 1000) + ' UTC',
number: (n) => numberFormat(tag).format(n),
decimal: (n, digits = 1) =>
new Intl.NumberFormat(tag, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).format(n),
};
}
/** Locale-free, so it lives here beside the rest rather than in two places. */
export function starString(rating: number | null): string {
if (rating === null) return '☆☆☆☆☆';
const filled = Math.round(rating);
return '★'.repeat(filled) + '☆'.repeat(Math.max(0, 5 - filled));
}
+87
View File
@@ -0,0 +1,87 @@
/**
* Build-time i18n entry point. Imported from .astro frontmatter, never from an island.
*
* Every catalog is imported statically here, which is exactly what we want at build
* time (one process renders all three locales) and exactly what we do not want in the
* browser. Islands import `./client` instead, which reads the one locale the page
* already carries. Nothing in this file reaches a bundle: Astro frontmatter runs on the
* build machine only.
*/
import en from './en.json';
import es from './es.json';
import nl from './nl.json';
import { CLIENT_NAMESPACES, DEFAULT_LOCALE, LOCALE_CODES, isLocale, type Locale } from './config.js';
import { createTranslator, type Catalog, type Translator } from './translate.js';
export * from './config.js';
export * from './routing.js';
export type { Translator, Vars } from './translate.js';
const CATALOGS: Record<string, Catalog> = {
en: en as Catalog,
es: es as Catalog,
nl: nl as Catalog,
};
/** The English catalog, which is what every other locale falls back to key by key. */
export const BASE_CATALOG: Catalog = CATALOGS[DEFAULT_LOCALE]!;
export function catalogFor(locale: Locale): Catalog {
return CATALOGS[locale] ?? BASE_CATALOG;
}
const translators = new Map<string, Translator>();
/**
* The translator for one locale. Cached, because every component on every page asks
* for it and there are ~180 pages.
*/
export function useI18n(locale: Locale): Translator {
const code = isLocale(locale) ? locale : DEFAULT_LOCALE;
let t = translators.get(code);
if (!t) {
t = createTranslator(code, catalogFor(code), BASE_CATALOG);
translators.set(code, t);
}
return t;
}
/**
* Which keys are missing from which locale, as the build reports them.
*
* English is the source of truth: a key it does not have is not a key. Exported so
* both the build warning in `astro.config.mjs` and `scripts/check-i18n.mjs` answer the
* question the same way.
*/
export function missingKeys(): Record<string, string[]> {
const expected = Object.keys(BASE_CATALOG);
const out: Record<string, string[]> = {};
for (const code of LOCALE_CODES) {
if (code === DEFAULT_LOCALE) continue;
const catalog = catalogFor(code);
const missing = expected.filter((key) => catalog[key] === undefined);
if (missing.length > 0) out[code] = missing;
}
return out;
}
/**
* The subset of a catalog an island can reach, resolved against English first.
*
* Two things happen here that matter. Keys outside `CLIENT_NAMESPACES` are dropped, so
* the Spanish home page does not inline the Dutch legal pages or its own. And every
* key that survives is resolved: a key this locale is missing arrives already filled
* with the English string, so the browser needs no fallback catalog and ships exactly
* one language.
*/
export function clientCatalog(locale: Locale): Catalog {
const catalog = catalogFor(locale);
const out: Catalog = {};
for (const key of Object.keys(BASE_CATALOG)) {
const namespace = key.split('.')[0] ?? '';
if (!(CLIENT_NAMESPACES as readonly string[]).includes(namespace)) continue;
out[key] = catalog[key] ?? BASE_CATALOG[key]!;
}
return out;
}
+14
View File
@@ -0,0 +1,14 @@
/**
* The locale codes, as plain JavaScript.
*
* `config.ts` is the real table and everything the site renders reads from it. This
* exists because two things run before TypeScript does: `astro.config.mjs`, which needs
* the codes to build the i18n config, and the build-time checker. Keeping it to the
* bare codes means the duplication is two short arrays that cannot drift far, and
* `scripts/check-i18n.mjs` compares the two on every build so they cannot drift at all.
*
* Adding a language: add the code here and the full entry in `config.ts`.
*/
export const DEFAULT_LOCALE = 'en';
export const LOCALE_CODES = ['en', 'es', 'nl'];
+59
View File
@@ -0,0 +1,59 @@
/**
* The bridge between `shared/warnings.ts` and the catalogs.
*
* `getMintWarnings` decides what is wrong with a mint and asks for the sentences by
* key; this hands it the sentences. Keeping the decision in shared and the words here
* is what lets the mint page, the mint card and the live-status island say the same
* thing in the same language without any of them knowing how the others render.
*
* Ships to the browser, so it stays to two small functions.
*/
import type { MintWarningOptions, WarningStrings } from '@cashumints/shared';
import type { Formatters } from './format.js';
import type { Translator } from './translate.js';
/** The catalog prefix the warning keys live under. */
const PREFIX = 'mint.warnings.';
/**
* A resolver over one locale's catalog.
*
* The plural handling that matters is already in `t()`: it uses `Intl.PluralRules`, so
* `dayCount` is right in a language with six plural categories without this function or
* shared/ knowing anything about it.
*/
export function warningStrings(t: Translator): WarningStrings {
return (key, vars) => t(PREFIX + key, vars);
}
/**
* Everything `getMintWarnings` needs to speak one locale: its sentences, and the date
* formats the sentences interpolate.
*
* Passed by the mint page at build time and by the live island at runtime, so a banner
* that a fresh probe replaces is in the same language as the one it replaced.
*/
export function warningOptions(f: Formatters): MintWarningOptions {
return {
strings: warningStrings(f.t),
formatDate: (unix) => f.date(unix),
formatMonth: (unix) => f.monthYear(unix),
};
}
/** The chip on a mint card, whose three labels sit under their own prefix. */
export function chipStrings(t: Translator): WarningStrings {
return (key) => t(`mint.chip.${key}`);
}
/**
* "Online", "Sin conexión", "Onbereikbaar".
*
* An unknown status renders as itself rather than as a humanised key: if the API ever
* grows a fifth state, showing the raw word is honest and showing a made-up label is
* not.
*/
export function statusLabel(status: string, t: Translator): string {
const key = `status.${status}`;
return t.has(key) ? t(key) : status;
}
+550
View File
@@ -0,0 +1,550 @@
{
"site.name": "Cashumints.space",
"common.close": "Sluiten",
"common.cancel": "Annuleren",
"common.done": "Klaar",
"common.back": "Terug",
"common.retry": "Opnieuw",
"common.connect": "Verbinden",
"common.copied": "Gekopieerd",
"common.copyFull": "Volledige {kind} kopiëren",
"common.previousPage": "Vorige pagina",
"common.nextPage": "Volgende pagina",
"common.reviewPages": "Reviewpagina's",
"time.never": "nooit",
"time.justNow": "net",
"time.none": "-",
"time.unknownDate": "een onbekende datum",
"time.ago.minute": "{n} min geleden",
"time.ago.hour": "{n} u geleden",
"time.ago.day": "{n} d geleden",
"time.ago.week": "{n} wk geleden",
"time.ago.month": "{n} mnd geleden",
"time.ago.year": "{n} jr geleden",
"time.short.minute": "{n} min",
"time.short.hour": "{n} u",
"time.short.day": "{n} d",
"time.short.week": "{n} wk",
"time.short.month": "{n} mnd",
"time.short.year": "{n} jr",
"status.online": "Online",
"status.offline": "Offline",
"status.degraded": "Onbereikbaar",
"status.unknown": "Nog niet gecontroleerd",
"nav.skip": "Naar de inhoud",
"nav.main": "Hoofdmenu",
"nav.mints": "Mints",
"nav.reviews": "Reviews",
"nav.wallets": "Wallets",
"nav.about": "Over",
"nav.searchMints": "Zoek in {n} mints",
"lang.label": "Taal",
"lang.switcher": "Kies een taal",
"footer.copyright": "© {year} Cashumints.space",
"footer.allMints": "Alle mints",
"footer.allReviews": "Alle reviews",
"footer.wallets": "Wallets",
"footer.about": "Over",
"footer.disclaimer": "Disclaimer",
"footer.terms": "Voorwaarden",
"footer.privacy": "Privacy",
"footer.madeBy": "Gemaakt met ❤️ door {azzamo} · Draait op Cashu en NIP-87",
"footer.warning": "Mints zijn custodiaal. Een mint kan er met jouw geld vandoor gaan.",
"home.title": "Cashumints.space · Vind een Cashu mint die je kunt vertrouwen",
"home.description": "Vind een Cashu mint die je kunt vertrouwen. {mints} mints geïndexeerd met {reviews} reviews uit de community, ondertekend op Nostr, plus live uptime, ondersteunde NUTs en mint-software.",
"home.hero.title": "Vind een Cashu mint",
"home.hero.titleEm": "die je kunt vertrouwen",
"home.hero.lede": "Elke mint op het Nostr-netwerk, met echte reviews uit de community. Geen poortwachters, alleen ondertekende reviews die je zelf kunt controleren.",
"home.search.label": "Zoek mints op naam, domein of URL",
"home.search.placeholder": "Zoek op naam of domein, of plak een mint-URL…",
"home.filters.mostReviewed": "Meeste reviews",
"home.filters.highestRated": "Best beoordeeld",
"home.filters.recentlyActive": "Recent actief",
"home.filters.websockets": "Ondersteunt WebSockets",
"home.top.title": "Beste mints",
"home.top.sub": "gerangschikt op reviews en beoordeling",
"home.top.all": "Alle {n} mints →",
"home.latest.title": "Laatste reviews",
"home.latest.sub": "live van het Nostr-netwerk",
"home.latest.prev": "Vorige reviews",
"home.latest.next": "Meer reviews",
"home.latest.all": "Alle reviews →",
"home.latest.empty": "Er kwamen op dit moment geen reviews van de relays. Open een willekeurige mintpagina om daar reviews te lezen en te schrijven.",
"home.why.title": "Waarom zou je deze reviews vertrouwen?",
"home.why.signed.title": "Ondertekend, niet ingediend",
"home.why.signed.body": "Elke review is een {link}-event, ondertekend met de Nostr-sleutel van wie het schreef. Wij bewerken, rangschikken of verwijderen hier niets.",
"home.why.signed.link": "NIP-87",
"home.why.verify.title": "Controleer het zelf",
"home.why.verify.body": "Reviews staan op publieke Nostr-relays, niet in onze database. Richt een willekeurige client op dezelfde relays en je ziet dezelfde gegevens.",
"home.why.checked.title": "Doorlopend gecontroleerd",
"home.why.checked.body": "We controleren elke mint op uptime, softwareversie, limieten en ondersteunde NUTs, dus wat je ziet is actueel en komt niet van de mint zelf.",
"home.cta.title": "Denk je aan een mint? Lees er eerst over.",
"home.cta.body": "Een mint houdt jouw ecash vast. Lees wat anderen tegenkwamen voordat je er sats in stopt, en laat zelf een review achter als je er een gebruikt hebt.",
"home.cta.explore": "Alle mints bekijken",
"home.cta.learn": "Meer over Cashu",
"pulse.mintsIndexed": "mints geïndexeerd",
"pulse.reviewsOnNostr": "reviews op Nostr",
"pulse.lastReview": "laatste review",
"pulse.mintsOnline": "mints online",
"pulse.indexer": "indexer",
"pulse.connected": "verbonden",
"pulse.stale": "verouderd",
"mints.title": "Alle Cashu mints · Cashumints.space",
"mints.description": "Alle {total} Cashu mints die op het Nostr-netwerk zijn geïndexeerd, {online} online en {offline} offline, met beoordelingen uit de community, uptime en mint-software. Doorzoek en sorteer de volledige lijst.",
"mints.heading": "Alle mints",
"mints.lede": "Elke mint die op het Nostr-netwerk gevonden is, ook de mints die er niet meer zijn. Offline mints blijven in de lijst staan zodat je ze nog kunt vinden en reviewen.",
"mints.search.label": "Zoek mints op naam of domein",
"mints.search.placeholder": "Zoek op naam of domein…",
"mints.sort.label": "Mints sorteren",
"mints.sort.score": "Algemeen beste",
"mints.sort.rating": "Best beoordeeld",
"mints.sort.reviews": "Meeste reviews",
"mints.sort.recent": "Recent gereviewd",
"mints.sort.seen": "Recent online gezien",
"mints.hideOffline": "Offline verbergen",
"mints.showing": "{total} mints te zien ({online} online, {offline} offline)",
"mints.showingFiltered": "{shown} van {total} mints te zien ({online} online, {offline} offline)",
"mints.noResults": "Geen enkele mint komt overeen. Probeer een domein, of plak een mint-URL.",
"mints.clearSearch": "Zoekopdracht wissen",
"card.noRatings": "Nog geen beoordelingen",
"card.reviews.one": "{n} review",
"card.reviews.other": "{n} reviews",
"card.lastSeen": "laatst gezien {when}",
"card.neverSeen": "nooit online gezien",
"card.reviewed": "laatste review {when}",
"card.noReviews": "nog geen reviews",
"mint.title": "{name} · Reviews van deze Cashu mint",
"mint.description.fallback": "{name} is een Cashu mint op {domain}.",
"mint.description.rated.one": "Beoordeeld met {rating} van de 5 op basis van {n} review, ondertekend op Nostr.",
"mint.description.rated.other": "Beoordeeld met {rating} van de 5 op basis van {n} reviews, ondertekend op Nostr.",
"mint.description.noReviews": "Nog geen reviews uit de community.",
"mint.description.online": "Nu online.",
"mint.description.unreachable": "Nu onbereikbaar.",
"mint.breadcrumb": "Kruimelpad",
"mint.crumbMints": "Mints",
"mint.copyUrl": "Mint-URL kopiëren",
"mint.qrButton": "Mint-QR",
"mint.writeReview": "Schrijf een review",
"mint.rankByScore": "#{rank} op score",
"mint.tagLightning": "Lightning",
"mint.summary": "Samenvatting van de mint",
"mint.rating.label": "Beoordeling uit de community",
"mint.rating.none": "Nog geen beoordelingen",
"mint.rating.reviews.one": "{n} review",
"mint.rating.reviews.other": "{n} reviews",
"mint.rating.unrated.one": "{n} gaf geen beoordeling",
"mint.rating.unrated.other": "{n} gaven geen beoordeling",
"mint.activity.label": "Activiteit",
"mint.activity.since": "sinds de laatste review",
"mint.activity.noReviews": "nog geen reviews",
"mint.activity.recent.one": "{n} review in de laatste 90 dagen",
"mint.activity.recent.other": "{n} reviews in de laatste 90 dagen",
"mint.limits.label": "Limieten",
"mint.limits.notPublished": "niet gepubliceerd",
"mint.limits.min": "min",
"mint.limits.max": "max",
"mint.limits.mintRow": "minten",
"mint.limits.meltRow": "melten",
"mint.limits.disabled": "uit",
"mint.limits.range": "{min} tot {max} {unit}",
"mint.limits.upTo": "tot {max} {unit}",
"mint.limits.from": "vanaf {min} {unit}",
"mint.software.label": "Mint-software",
"mint.software.unknown": "onbekend",
"mint.checked": "gecontroleerd {when}",
"mint.lastSeen": "laatst gezien {when}",
"mint.neverReached": "nooit bereikt",
"mint.uptime.checks": "{ok} van de laatste {total} controles bereikbaar",
"mint.uptime.percent": "{percent}% bereikbaar over 30 dagen",
"mint.uptime.description": "{ok} van de laatste {total} controles slaagden",
"mint.operator.title": "Van de beheerder",
"mint.operator.motd": "Bericht van de dag",
"mint.operator.beta": "Deze mint noemt zichzelf experimenteel of beta. Lees hieronder wat de beheerder er zelf over zegt voordat je er iets in bewaart dat je niet kunt missen.",
"mint.nuts.title": "Ondersteunde NUTs",
"mint.nuts.noneOffline": "Er is geen NUT-lijst voor deze mint bewaard, hij ging offline voordat er een vastgelegd was.",
"mint.nuts.noneYet": "Er is nog geen NUT-lijst voor deze mint bewaard.",
"mint.nuts.supported": "{n} van de {total} bekende NUTs ondersteund",
"mint.nuts.whatAre": "wat zijn NUTs?",
"mint.nutRow.disabled": "uit",
"mint.nutRow.notSupported": "niet ondersteund",
"mint.details.title": "Gegevens van de mint",
"mint.details.pubkey": "Pubkey",
"mint.details.nostr": "Nostr",
"mint.details.email": "E-mail",
"mint.details.serverTime": "Servertijd",
"mint.details.firstSeen": "Eerst gezien",
"mint.details.terms": "Voorwaarden",
"mint.kind.identifier": "identificatie",
"mint.kind.mintPubkey": "pubkey van de mint",
"mint.kind.nostrAddress": "Nostr-adres",
"mint.kind.emailAddress": "e-mailadres",
"mint.kind.npub": "npub",
"mint.disclaimer": "Deze mint is custodiaal: hij houdt jouw geld vast, en hij kan daar op elk moment mee stoppen. Beoordelingen zijn meningen uit de community, en online betekent bereikbaar, niet betrouwbaar.",
"mint.disclaimerLink": "Lees de volledige disclaimer",
"mint.qr.title": "{name} aan je wallet toevoegen",
"mint.qr.note": "Scan dit met een Cashu wallet om de mint toe te voegen, of kopieer de URL en plak hem daar.",
"mint.qr.warn": "Een mint toevoegen betekent dat je hem je geld toevertrouwt.",
"mint.qr.whatMeans": "Wat dat betekent",
"mint.qr.wallets": "Wallets die Cashu ondersteunen",
"mint.chip.frozen": "Bevroren",
"mint.chip.noWithdrawals": "Geen opnames",
"mint.chip.meltOnly": "Alleen melten",
"mint.warnings.dayCount.one": "{n} dag",
"mint.warnings.dayCount.other": "{n} dagen",
"mint.warnings.gone.lead": "Waarschijnlijk verdwenen.",
"mint.warnings.gone.body.since": "Deze mint is onbereikbaar sinds {date} ({days}). Beschouw geld dat je er hebt staan als verloren, en stuur er niets nieuws heen. Wat hieronder staat is de laatst bekende staat.",
"mint.warnings.gone.body.neverDated": "Deze mint heeft geen enkele controle beantwoord sinds we hem in {month} zijn gaan volgen. Beschouw geld dat je er hebt staan als verloren, en stuur er niets nieuws heen. Alles hieronder komt uit de aankondiging op Nostr, niet van de mint zelf.",
"mint.warnings.gone.body.never": "Deze mint heeft geen enkele controle beantwoord sinds we hem zijn gaan volgen. Beschouw geld dat je er hebt staan als verloren, en stuur er niets nieuws heen. Alles hieronder komt uit de aankondiging op Nostr, niet van de mint zelf.",
"mint.warnings.gone.meta.since": "Offline sinds {date}",
"mint.warnings.gone.meta.never": "Nooit bereikbaar geweest sinds hij gevonden werd",
"mint.warnings.meltDisabled.lead": "Opnemen staat uit.",
"mint.warnings.meltDisabled.body.online": "Deze mint heeft melten via Lightning (NUT-05) uitgezet: je kunt er sats in stoppen, maar je krijgt ze er via Lightning niet uit. Stort niets tot melten weer werkt.",
"mint.warnings.meltDisabled.body.offline": "Deze mint had melten via Lightning (NUT-05) uitgezet: je kunt er sats in stoppen, maar je krijgt ze er via Lightning niet uit. Stort niets tot melten weer werkt.",
"mint.warnings.meltDisabled.meta.online": "Opnemen staat nu uit",
"mint.warnings.meltDisabled.meta.offline": "Opnemen stond uit toen hij voor het laatst gezien werd",
"mint.warnings.frozen.lead": "Mint bevroren.",
"mint.warnings.frozen.body.online": "Minten en melten staan allebei uit. Er gaat geen enkele sat in of uit via Lightning.",
"mint.warnings.frozen.body.offline": "Minten en melten stonden allebei uit. Er ging geen enkele sat in of uit via Lightning.",
"mint.warnings.frozen.meta.online": "Nu bevroren, er gaat niets in of uit",
"mint.warnings.frozen.meta.offline": "Bevroren toen hij voor het laatst gezien werd",
"mint.warnings.offlineLong.lead": "Al {days} offline, sinds {date}.",
"mint.warnings.offlineLong.body": "Deze mint is misschien weg. Alles hieronder is zijn laatst bekende staat, en reviews werken nog gewoon.",
"mint.warnings.offlineLong.meta": "Offline sinds {date}",
"mint.warnings.meltOnly.lead": "Alleen melten.",
"mint.warnings.meltOnly.body.online": "Nieuw minten (NUT-04) staat uit: je kunt niet storten, maar ecash dat je al hebt kun je nog wel opnemen. Vaak betekent dit dat een mint aan het afbouwen is, dus als je er ecash van hebt, overweeg het eruit te melten.",
"mint.warnings.meltOnly.body.offline": "Nieuw minten (NUT-04) stond uit: je kunt niet storten, maar ecash dat je al hebt kun je nog wel opnemen. Vaak betekent dit dat een mint aan het afbouwen is, dus als je er ecash van hebt, overweeg het eruit te melten.",
"mint.warnings.meltOnly.meta.online": "Nu alleen melten",
"mint.warnings.meltOnly.meta.offline": "Alleen melten toen hij voor het laatst gezien werd",
"mint.warnings.offline.lead": "Offline sinds {date}.",
"mint.warnings.offline.body": "De gegevens zijn toen voor het laatst bevestigd. Je kunt nog steeds een review schrijven.",
"mint.warnings.offline.meta": "Offline sinds {date}",
"mint.warnings.also.meltDisabled": "Voordat hij offline ging had hij opnemen ook al uitgezet.",
"mint.warnings.also.mintDisabled": "Voordat hij offline ging had hij nieuw minten ook al uitgezet.",
"mint.warnings.also.offline": "Hij is bovendien offline sinds {date} ({days}).",
"nut.0": "Notatie en modellen",
"nut.1": "Uitwisseling van de publieke sleutel van de mint",
"nut.2": "Keysets en kosten",
"nut.3": "Tokens wisselen",
"nut.4": "Minten via Lightning",
"nut.5": "Melten via Lightning",
"nut.6": "Mint-informatie",
"nut.7": "Tokenstatus controleren",
"nut.8": "Te veel betaalde Lightning-kosten",
"nut.9": "Handtekeningen herstellen",
"nut.10": "Uitgavevoorwaarden",
"nut.11": "Betalen aan publieke sleutel",
"nut.12": "DLEQ-bewijzen",
"nut.13": "Deterministische secrets",
"nut.14": "Hashed timelock contracts",
"nut.15": "Betalingen via meerdere paden",
"nut.16": "Bewegende QR-codes",
"nut.17": "WebSocket-abonnementen",
"nut.18": "Betaalverzoeken",
"nut.19": "Antwoorden uit cache",
"nut.20": "Ondertekende mint-quotes",
"nut.21": "Open authenticatie",
"nut.22": "Blinde authenticatie",
"nut.23": "BOLT11-betaalmethode",
"nut.24": "HTTP 402, betaling vereist",
"nut.25": "BOLT12-betaalmethode",
"reviews.title": "Reviews",
"reviews.anon": "Anon",
"reviews.filter.group": "Reviews filteren",
"reviews.filter.all": "Alle",
"reviews.filter.five": "5★",
"reviews.filter.critical": "Kritisch",
"reviews.filter.recent": "Recent",
"reviews.loading.one": "{n} review van Nostr-relays laden",
"reviews.loading.other": "{n} reviews van Nostr-relays laden",
"reviews.empty": "Nog geen reviews. Wees de eerste die deze mint reviewt.",
"reviews.emptyWritten": "Hier staan nog geen geschreven reviews. Wees de eerste die er een schrijft.",
"reviews.emptyFilter": "Geen enkele review past bij dit filter.",
"reviews.noscript": "Reviews worden op Nostr ondertekend en in je browser geladen, dus deze lijst heeft JavaScript nodig. De beoordeling en de aantallen hierboven komen van de site zelf.",
"reviews.relayError": "De relay was niet bereikbaar. De reviews zijn misschien niet compleet.",
"reviews.srRating": "{rating} van de 5",
"reviews.srNoRating": "Geen beoordeling gegeven",
"reviews.anonNote": "Eerste review van deze npub, verder geen activiteit gevonden",
"reviews.published": "Gepubliceerd.",
"reviews.publishedPartial": "Gepubliceerd op {accepted} van de {total} relays.",
"reviews.propagating": "Wordt verspreid, het kan even duren voordat hij elders verschijnt.",
"reviews.summary.line.one": "{count} beoordeling zonder tekst: {breakdown}",
"reviews.summary.line.other": "{count} beoordelingen zonder tekst: {breakdown}",
"reviews.summary.lineDay.one": "{count} beoordeling zonder tekst op {when}: {breakdown}",
"reviews.summary.lineDay.other": "{count} beoordelingen zonder tekst op {when}: {breakdown}",
"reviews.summary.showOne": "Laat zien",
"reviews.summary.showMany": "Laat zien",
"reviews.breakdown.5": "{n} van vijf sterren",
"reviews.breakdown.4": "{n} van vier sterren",
"reviews.breakdown.3": "{n} van drie sterren",
"reviews.breakdown.2": "{n} van twee sterren",
"reviews.breakdown.1": "{n} van één ster",
"reviews.breakdown.none": "{n} zonder beoordeling",
"reviews.dialog.title": "{name} reviewen",
"reviews.dialog.note": "Wordt ondertekend met jouw Nostr-sleutel en gepubliceerd op publieke relays. Hij kan niet bewerkt of verwijderd worden.",
"reviews.dialog.ratingLegend": "Jouw beoordeling",
"reviews.dialog.starsSr.one": "{n} ster",
"reviews.dialog.starsSr.other": "{n} sterren",
"reviews.dialog.chooseRating": "Kies een beoordeling",
"reviews.dialog.starLabel": "{n}: {phrase}",
"reviews.dialog.stars.1": "blijf er weg",
"reviews.dialog.stars.2": "had problemen",
"reviews.dialog.stars.3": "werkte, met kanttekeningen",
"reviews.dialog.stars.4": "goed",
"reviews.dialog.stars.5": "werkt prima",
"reviews.dialog.bodyLabel": "Wat moeten anderen weten?",
"reviews.dialog.bodyPlaceholder": "Hoe ging het minten, het melten en de support? Wees concreet.",
"reviews.dialog.charCount": "{n} / {max}",
"reviews.dialog.submit": "Ondertekenen en publiceren",
"reviews.dialog.signing": "Ondertekenen…",
"reviews.dialog.publishing": "Publiceren…",
"reviews.dialog.publishingTo": "Publiceren op relays ({accepted}/{total})",
"reviews.dialog.waitingExtension": "Wachten op je extensie",
"reviews.dialog.waitingSigner": "Wachten op je signer",
"reviews.dialog.confirmClose": "Deze review sluiten? Je concept blijft bewaard tot je de site verlaat.",
"reviews.dialog.why.login": "Log in om te publiceren",
"reviews.dialog.why.rating": "Kies een beoordeling",
"reviews.dialog.why.words": "Schrijf een paar woorden",
"reviews.dialog.why.more.one": "nog {n} teken",
"reviews.dialog.why.more.other": "nog {n} tekens",
"reviews.dialog.why.over.one": "{n} teken te veel",
"reviews.dialog.why.over.other": "{n} tekens te veel",
"reviews.dialog.identity.needSigner": "Je hebt een Nostr-signer nodig om te publiceren. Je sleutel blijft daarin, deze site ziet hem nooit.",
"reviews.dialog.identity.loginToPublish": "Log in om te publiceren",
"reviews.dialog.identity.switch": "Wisselen",
"reviews.dialog.identity.extension": "Ondertekenen met je browserextensie",
"reviews.dialog.identity.readingKey": "Je publieke sleutel lezen…",
"reviews.dialog.identity.willAsk": "Je extensie krijgt de vraag om te ondertekenen",
"reviews.dialog.error.noRelay": "Geen enkele relay heeft de review aangenomen. Controleer je verbinding en probeer het opnieuw.",
"reviews.validate.rating": "Kies een beoordeling van 1 tot 5 sterren.",
"reviews.validate.short": "Schrijf minstens {n} tekens, anders heeft niemand iets aan de review.",
"reviews.validate.long": "Reviews mogen maximaal {n} tekens lang zijn.",
"feed.title": "Laatste reviews van Cashu mints · Cashumints.space",
"feed.description": "Elke review van een Cashu mint zodra hij verschijnt: {reviews} reviews over {mints} mints, ondertekend op Nostr en hier te lezen met de nieuwste bovenaan. Filter op beoordeling of op mint.",
"feed.heading": "Laatste reviews van Cashu mints",
"feed.lede": "Elke review van elke mint, nieuwste bovenaan, live gelezen van de Nostr-relays. Elke review is ondertekend door wie hem schreef en staat op de relays, niet in onze database.",
"feed.label": "Reviews",
"feed.filterByMint": "Filteren op mint",
"feed.anyMint": "Elke mint",
"feed.clearMint": "Mintfilter wissen",
"feed.showingBuild.one": "De meest recente review van de laatste build te zien",
"feed.showingBuild.other": "De {n} meest recente reviews van de laatste build te zien",
"feed.noscript": "Reviews worden op Nostr ondertekend en in je browser geladen, dus filteren en de volledige lijst hebben JavaScript nodig. De {n} reviews hieronder zijn van de relays gelezen toen deze pagina gebouwd werd.",
"feed.emptyBuild": "Er konden geen reviews van de relays gelezen worden toen deze pagina gebouwd werd. De lijst wordt in je browser opnieuw van de relays geladen.",
"feed.foot": "Reviews zijn meningen uit de community, geen gecontroleerde feiten. Lees de {link} voordat je een mint je geld toevertrouwt.",
"feed.footLink": "disclaimer",
"feed.showing": "{shown} van {total} reviews te zien",
"feed.showingMatching": "{shown} van {matching} passende reviews te zien ({total} in totaal)",
"feed.emptyRelays": "Er kwamen geen reviews terug van de relays. Open een willekeurige mintpagina om daar reviews te lezen en te schrijven.",
"feed.emptyFilter": "Geen enkele review past bij dit filter. Maak het mintveld leeg, of probeer Alle.",
"feed.relayError": "De relays waren niet bereikbaar. De reviews van de laatste build zijn te zien.",
"feed.relayEmpty": "Er kwamen geen reviews terug van de relays. De reviews van de laatste build zijn te zien.",
"feed.reading": "De relays lezen…",
"login.open": "Inloggen",
"login.title": "Inloggen met Nostr",
"login.note": "Je sleutel blijft in je signer. Deze site kan hem nooit zien.",
"login.extension.name": "Browserextensie",
"login.extension.desc": "Onderteken met Alby, nos2x of een andere NIP-07-extensie",
"login.extension.none": "Geen extensie gevonden",
"login.extension.getOne": "Haal er een:",
"login.remote.name": "Signer op afstand",
"login.remote.desc": "Scan een code met een signer-app, of plak een bunker-link",
"login.primal.name": "Primal",
"login.primal.desc": "Keur het inloggen goed in de Primal-app op je telefoon",
"login.paste.label": "Of plak de verbindingslink die je signer je gaf",
"login.copyLink": "De verbindingslink kopiëren",
"login.whatIsNostr": "Wat is Nostr?",
"login.connect.remote.title": "Een signer op afstand verbinden",
"login.connect.remote.lead": "Scan dit met je signer-app, of kopieer de link en plak hem daar.",
"login.connect.remote.hint": "Werkt met elke NIP-46-signer: nsec.app, Amber, Nostrify, je eigen bunker.",
"login.connect.primal.title": "Verbinden met Primal",
"login.connect.primal.lead": "Open Primal op je telefoon, zoek ondertekenen op afstand in de instellingen, en scan deze code.",
"login.connect.primal.hint": "Primal ondertekent via NIP-46 voor andere apps, dus dit is dezelfde verbinding die elke signer gebruikt, en je sleutel blijft in Primal. {link}",
"login.status.buildingLink": "Een verbindingslink maken",
"login.status.waitingExtension": "Wachten op je extensie",
"login.status.waitingSigner": "Wachten op je signer",
"login.status.connecting": "Verbinden met je signer",
"login.status.connected": "Verbonden",
"login.method.extension": "Extensie",
"login.method.remote": "Op afstand",
"login.method.primal": "Primal",
"login.account": "Jouw Nostr-account",
"login.copyNpub": "Je volledige npub kopiëren",
"login.logout": "Uitloggen",
"signer.status.waitingExtension": "Wachten op je extensie",
"signer.status.signed": "Ondertekend",
"signer.status.reconnecting": "Opnieuw verbinden met je signer",
"signer.status.approveWindow": "Je signer wil dat je dit in zijn eigen venster goedkeurt",
"signer.status.waitingSigner": "Wachten op je signer",
"signer.status.stillWaiting": "Nog steeds wachten tot je signer antwoordt",
"signer.status.connected": "Verbonden",
"signer.error.noExtension": "Geen ondertekenextensie gevonden. Installeer Alby of nos2x en herlaad deze pagina.",
"signer.error.privateKey": "Dat lijkt op een privésleutel, niet op een verbindingslink. Deze site neemt nooit een sleutel aan. Plak de bunker://-link die je signer je gaf.",
"signer.error.badLink": "Dat is geen verbindingslink die deze site begrijpt. Plak de bunker://-link van je signer.",
"signer.error.noAnswer": "Je signer antwoordde niet. Hij is misschien afgesloten, of zijn relay ligt eruit. Open hem en probeer het opnieuw.",
"signer.error.timeout": "Je signer antwoordde niet op tijd. Open hem, kijk of hij nog verbonden is, en probeer het opnieuw.",
"signer.error.cancelled": "Verbinding geannuleerd.",
"signer.error.declined": "Het ondertekenverzoek is geweigerd",
"notfound.title": "Pagina niet gevonden · Cashumints.space",
"notfound.description": "Die pagina bestaat niet op Cashumints.space.",
"notfound.heading": "Deze pagina bestaat niet",
"notfound.lede": "Misschien is hij verplaatst, of is de mint nog niet geïndexeerd. Zoek een mint op naam of domein, of plak een mint-URL.",
"notfound.browse": "Alle mints bekijken",
"notfound.home": "Naar de startpagina",
"notfound.loading": "{host} laden",
"notfound.fb.rating": "Beoordeling",
"notfound.fb.reviews": "Reviews",
"notfound.fb.software": "Software",
"notfound.fb.lastOnline": "Laatst online",
"notfound.fb.note": "Deze mint is geïndexeerd nadat de site voor het laatst gebouwd is, dus dit is een samenvatting uit de live API. De volledige pagina met reviews verschijnt bij de volgende build.",
"wallets.title": "Cashu wallets · Cashumints.space",
"wallets.description": "Cashu wallets die werken met de mints die hier staan, voor mobiel, web, browser en de opdrachtregel, met waar elke wallet goed in is.",
"wallets.heading": "Wallets",
"wallets.lede": "Je hebt een wallet nodig om ecash van deze mints in te bewaren. De meeste wallets praten met meerdere mints tegelijk, dus je zit niet vast aan de eerste die je probeert.",
"wallets.note": "Deze lijst is niet volledig en is geen aanbeveling. Het Cashu-project houdt een bredere index bij op {link}. Een wallet bewaart sleutels, een mint bewaart het geld: lees de reviews van de mints voordat je kiest waar je sats wonen.",
"wallets.platform.android": "Android",
"wallets.platform.ios": "iOS",
"wallets.platform.web": "Web",
"wallets.platform.extension": "Browserextensie",
"wallets.platform.cli": "Opdrachtregel",
"wallets.platform.python": "Python",
"wallets.item.minibits": "Mobiele ecash wallet met een eigen mint, ondersteuning voor Lightning-adressen en Nostr-contacten.",
"wallets.item.nutstash": "Browserwallet die meerdere mints tegelijk aankan en ruwe tokens importeert en exporteert.",
"wallets.item.enuts": "Mobiele wallet gericht op saldo's bij meerdere mints en het versturen van ecash via Nostr.",
"wallets.item.cashume": "De referentiewallet voor het web van het Cashu-project. Draait volledig in de browser en is te installeren als PWA.",
"wallets.item.boardwalk": "Webwallet gebouwd rond Lightning-adressen en het versturen van ecash via een link.",
"wallets.item.coinos": "Lightning wallet en kassasysteem dat ook een eigen Cashu mint draait.",
"wallets.item.oxchat": "Nostr-messenger met een ingebouwde ecash wallet, om sats in een gesprek te versturen.",
"wallets.item.nutshell": "De referentie-implementatie. Draait als wallet op de opdrachtregel en als mint-software.",
"about.title": "Over Cashumints.space",
"about.description": "Wat Cashu is, wat een mint werkelijk vasthoudt, wat NIP-87 is, en hoe deze site mints indexeert en reviews van Nostr-relays leest.",
"about.heading": "Over",
"about.lede": "Cashumints.space toont elke Cashu mint die we op het Nostr-netwerk kunnen vinden en laat zien wat mensen erover zeggen. De site is gemaakt door Azzamo.",
"about.cashu.title": "Wat Cashu is",
"about.cashu.p1": "Cashu is een ecash-protocol voor Bitcoin. Een mint geeft blinde tokens uit die gedekt zijn door sats die hij vasthoudt, en die tokens gaan van persoon naar persoon zonder dat de mint te weten komt wie wat aan wie stuurde. De privacy is echt en is het hele punt: tokens zijn geen rekeningen, en de mint kan het token dat hij ondertekende niet koppelen aan het token dat terugkomt.",
"about.cashu.p2": "Je kunt het protocol en de specificaties lezen op {link}. De specificaties heten NUTs, en daar komen de NUT-nummers op elke mintpagina vandaan.",
"about.mints.title": "Wat een mint is, en wat het je kost",
"about.mints.p1": "Een mint is custodiaal. Als je ecash mint geef je sats aan wie die server draait, en vertrouw je erop dat die de tokens honoreert als je ze er weer uit melt. Er is geen fraudebewijs, geen escrow en geen verhaal. Wie een mint beheert kan er met het saldo vandoor gaan, en dat is gebeurd.",
"about.mints.p2": "Precies daarom bestaan reviews hier. Deze site vertelt je nooit dat een mint veilig is. De status op een mintpagina zegt {code}, niet \"betrouwbaar\", en betekent alleen dat de server antwoordde toen we het voor het laatst vroegen. Behandel ecash-saldo's als los geld in een jaszak: prima om uit te geven, verkeerd om te sparen.",
"about.nip87.title": "Wat NIP-87 is",
"about.nip87.p1": "NIP-87 is de Nostr-afspraak voor het aankondigen en aanbevelen van ecash mints. Twee soorten events doen er hier toe:",
"about.nip87.li1": "{code} kondigt een mint aan. De mint-URL staat in de {tag}-tag. Zo vindt de site mints die hij nog nooit gezien heeft.",
"about.nip87.li2": "{code} is een review. Hij bevat de beoordeling en de tekst, en wijst naar de mint waar hij over gaat met een {utag}-tag (de mint-URL) en een {dtag}-tag (de eigen publieke sleutel van de mint).",
"about.nip87.p2": "Elke review is ondertekend met de Nostr-sleutel van wie hem schreef. We noemen een review nooit \"geverifieerd\", want niemand heeft hem geverifieerd: hij is ondertekend, en dat is iets anders en nuttiger. Het betekent dat je kunt nagaan wie hem schreef en dat er niets aan veranderd is. Het betekent niet dat wie hem schreef eerlijk is of de mint ooit gebruikt heeft.",
"about.how.title": "Hoe deze site werkt",
"about.how.p1": "Er zijn twee lussen en één regel. De regel is dat de mint eigenaar is van zijn eigen gegevens en Nostr eigenaar is van de reviews, dus deze site bewaart geen van beide als autoriteit.",
"about.how.li1": "{strong} vraagt elke bekende mint om de tien minuten om {code} en legt vast wat die zegt: naam, omschrijving, versie, ondersteunde NUTs, limieten, contactgegevens. Hij legt ook vast of de mint überhaupt antwoordde, en daar komt de uptime vandaan.",
"about.how.li1Label": "De controlelus",
"about.how.li2": "{strong} maakt elk uur verbinding met Nostr-relays, leest mintaankondigingen en reviews, en voegt elke mint toe die hij nog niet gezien had.",
"about.how.li2Label": "De ontdekkingslus",
"about.how.p2": "Het belangrijkste is wat er gebeurt als een mint stopt met antwoorden. Zijn bewaarde gegevens worden nooit overschreven door een mislukte controle, dus een dode mint houdt zijn volledige pagina: zijn specificaties, zijn geschiedenis en zijn reviews, met een waarschuwing die zegt wanneer hij donker werd. De vorige versie van deze site liet een foutpagina zien, en dat betekende dat juist de mints waar mensen elkaar het hardst voor moesten waarschuwen niet te reviewen waren. Dat was de belangrijkste reden om de site opnieuw te bouwen.",
"about.how.p3": "Reviewteksten worden bij ons nooit bewaard. Als je een mintpagina opent praat je browser rechtstreeks met de relays en leest hij de reviews zelf, en als je er een schrijft wordt hij ondertekend door je eigen Nostr-extensie en gaat hij regelrecht naar die relays. Wij houden een telling bij zodat de pagina's gerangschikt en gesorteerd kunnen worden, meer niet. Wij kunnen hier niets bewerken of verwijderen, ook geen reviews die we liever niet hadden gehad.",
"about.relays.title": "Relays die we lezen",
"about.relays.p1": "Dit zijn de relays die zowel de indexer als jouw browser gebruiken:",
"about.relays.p2": "Richt een willekeurige Nostr-client op dezelfde relays en je ziet dezelfde events. Dat is de hele belofte: hier is niets waar alleen omdat wij het zeggen.",
"about.nuts.title": "Wat zijn NUTs?",
"about.nuts.p1": "NUTs zijn de genummerde Cashu-specificaties, zoals BIPs voor Bitcoin. Een mint geeft aan welke hij toepast, en dat vertelt je wat hij echt kan: NUT-04 en NUT-05 zijn minten en melten via Lightning (zonder die twee heb je weinig aan een mint), NUT-07 laat een wallet nagaan of een token al uitgegeven is, NUT-17 geeft wallets live updates via WebSockets in plaats van steeds te moeten vragen, en NUT-20 laat een wallet bewijzen dat een mint-quote echt aan hem is uitgegeven. De volledige lijst staat op {link}.",
"about.numbers.title": "Op dit moment",
"about.numbers.p1": "{mints} mints geïndexeerd, waarvan er {online} antwoorden, {reviews} reviews geteld van de relays hierboven.",
"about.source.title": "Broncode en credits",
"about.source.p1": "De site is open source op {github}. Hij is gebouwd en wordt beheerd door {azzamo}. Staat er iets fout op een mintpagina, los het dan bij de bron op: de gegevens van een mint komen uit zijn eigen {code}, en reviews komen van Nostr.",
"terms.title": "Gebruiksvoorwaarden · Cashumints.space",
"terms.description": "Gebruiksvoorwaarden voor Cashumints.space: wat de site is, wat hij niet doet, en wie waarvoor verantwoordelijk is. Geen accounts, geen garanties, geen band met welke mint dan ook.",
"terms.heading": "Gebruiksvoorwaarden",
"terms.lede": "Nuchtere voorwaarden voor een site die alleen publieke gegevens indexeert. Laatst bijgewerkt op {date}.",
"terms.what.title": "Wat deze site is",
"terms.what.p1": "Cashumints.space is een index van openbaar beschikbare gegevens over Cashu mints. Twee soorten gegevens, allebei van elders:",
"terms.what.li1": "Gegevens van de mint, gelezen van het {code}-endpoint van de mint zelf: naam, omschrijving, softwareversie, ondersteunde NUTs, contactgegevens en of de mint antwoordde toen we het voor het laatst vroegen.",
"terms.what.li2": "Reviews, gelezen van publieke Nostr-relays. Dat zijn NIP-87-events die door hun auteurs zijn gepubliceerd op relays die iedereen kan bevragen.",
"terms.what.p2": "Wij verzamelen die gegevens, maken ze uniform, rangschikken ze en tonen ze. Wij maken ze niet.",
"terms.noAccounts.title": "Geen accounts, geen garanties",
"terms.noAccounts.p1": "Er valt nergens iets aan te melden. De site wordt geleverd zoals hij is, zonder enige garantie: niet dat de gegevens kloppen, niet dat ze actueel zijn, en niet dat de site beschikbaar zal zijn. Gegevens van een mint kunnen minuten of uren oud zijn, een mint kan veranderd zijn na onze laatste controle, en een relay kan niet antwoorden, en dan zie je minder dan er is.",
"terms.noAccounts.p2": "Gebruik hem op eigen risico. Wij zijn niet aansprakelijk voor verlies dat volgt uit het gebruik van deze site of uit het vertrouwen op wat je hier leest. Lees de {link} voor de specifieke risico's van het gebruik van een Cashu mint.",
"terms.noAccounts.link": "disclaimer",
"terms.noCustody.title": "Wij houden jouw geld niet vast",
"terms.noCustody.p1": "Deze site neemt nooit geld in bewaring. Hij heeft geen wallet, raakt geen ecash aan, en kan niets verplaatsen, bevriezen of terughalen. Heb je een probleem met een mint, dan is de beheerder van die mint de enige partij die je kan helpen, en wij kunnen die niet namens jou benaderen.",
"terms.noCustody.p2": "Wij hebben geen band met welke mint hier ook staat, en geen enkele mint betaalt voor zijn vermelding, zijn plek of zijn beoordeling. Mints worden gevonden via publieke aankondigingen op Nostr en gerangschikt door een algoritme over publieke reviewgegevens.",
"terms.reviews.title": "Reviews zijn de woorden van anderen",
"terms.reviews.p1": "Reviews zijn door gebruikers gemaakte inhoud die hun auteurs op publieke Nostr-relays publiceren. Wij tonen ze, wij hebben er geen zeggenschap over. Wij hebben ze niet geschreven, wij kunnen ze niet bewerken, en wij kunnen ze niet verwijderen van de relays die ze bewaren: het zijn ondertekende events op een netwerk dat niet van ons is.",
"terms.reviews.p2": "Een review is de ervaring van één persoon, geen gecontroleerd feit. Publiceer je er een, dan ben je verantwoordelijk voor wat erin staat. Publiceer niets dat onwettig is, en besef dat wat je publiceert openbaar en blijvend is (zie de {link}).",
"terms.reviews.link": "privacypagina",
"terms.reviews.p3": "Wij kunnen een bepaalde review op deze site niet meer tonen, en dat doen we bij inhoud die onwettig is. Dat haalt hem alleen van deze pagina's af. Het event blijft op de relays staan en elke andere Nostr-client kan het nog steeds lezen.",
"terms.links.title": "Links naar andere sites",
"terms.links.p1": "Mint-URL's, links naar wallets, contactgegevens van beheerders en al het andere dat van deze site af wijst staat er omdat het bij de gegevens hoort. Een link is geen aanbeveling en wij controleren niet wat er aan de andere kant staat.",
"terms.changes.title": "Wijzigingen",
"terms.changes.p1": "Deze voorwaarden kunnen veranderen. De datum bovenaan zegt wanneer dat voor het laatst gebeurde. De site is open source, dus elke wijziging van deze pagina staat in de commitgeschiedenis.",
"terms.contact.title": "Contact",
"terms.contact.p1": "Meldingen en verzoeken om iets te verwijderen kun je het beste indienen op {github}. De site wordt beheerd door {azzamo}.",
"privacy.title": "Privacy · Cashumints.space",
"privacy.description": "Privacy op Cashumints.space: geen accounts, geen trackingcookies, geen analytics. localStorage wordt gebruikt om profielen te cachen en er gaat niets ergens naartoe. Reviews worden in je eigen browser ondertekend.",
"privacy.heading": "Privacy",
"privacy.lede": "We verzamelen zo weinig als een website maar kan. Laatst bijgewerkt op {date}.",
"privacy.noAccounts.title": "Geen accounts",
"privacy.noAccounts.p1": "Er is geen aanmelding, geen login en geen profiel. Niets op deze site vraagt wie je bent, en er is geen gebruikersgegeven dat kan uitlekken omdat er geen gebruikers zijn.",
"privacy.noTracking.title": "Geen trackingcookies, geen analytics",
"privacy.noTracking.p1": "De site zet geen cookies om je te volgen. Er is geen analyticsscript, geen tagmanager, geen pixel, geen sessieopname en geen advertentienetwerk. Niets op de pagina rapporteert jouw gedrag aan ons of aan iemand anders.",
"privacy.logs.title": "Serverlogs",
"privacy.logs.p1": "De webserver houdt gewone toegangslogs bij, zoals elke webserver standaard doet. Een logregel legt je IP-adres vast, het tijdstip, de pagina die je opvroeg, de responsstatus, en de user agent en de referrer die je browser meestuurde. De logs bestaan om fouten op te sporen en misbruik te zien. Ze worden nergens aan gekoppeld en ze worden niet verkocht of gedeeld.",
"privacy.logs.operatorLabel": "Noot voor de beheerder:",
"privacy.logs.operator": "draai jij deze site, zet hier dan je echte bewaartermijn neer. De opzet in de README gebruikt nginx met het standaard toegangslog en de rotatie die jouw distributie meelevert (op Debian en Ubuntu is dat logrotate, dagelijks, met 14 dagen bewaring). Kijk na wat jouw server doet en vervang deze alinea door dat getal.",
"privacy.storage.title": "Wat er in je browser wordt bewaard",
"privacy.storage.p1": "De site gebruikt localStorage voor precies twee dingen, en die blijven allebei op je apparaat en gaan nergens heen:",
"privacy.storage.li1": "Een cache van Nostr-profielgegevens (kind 0-events: namen, avatars, NIP-05-adressen) van de mensen wier reviews je bekeken hebt. Zo hoeven de relays niet op elke pagina opnieuw bevraagd te worden. Regels verlopen, en de cache heeft een maximum en gooit de oudste er als eerste uit.",
"privacy.storage.li2": "Voorkeuren voor de interface, zodat de site zich de volgende keer hetzelfde gedraagt.",
"privacy.storage.p2": "Als je de sitegegevens in je browser wist verdwijnen ze allebei. Er gaat niets stuk, de profielen worden gewoon opnieuw opgezocht.",
"privacy.thirdParties.title": "Waar je browser mee praat",
"privacy.thirdParties.p1": "Een pagina laden verbindt je browser met een paar plekken buiten deze site. Zo leest een site zonder tracking aan de serverkant toch live gegevens:",
"privacy.thirdParties.li1Label": "Publieke Nostr-relays",
"privacy.thirdParties.li1": "{strong}, via WebSocket, om reviews en profielen te lezen. De relay ziet je IP en wat je opvroeg, zoals elke server waarmee je verbinding maakt. De relays staan op de {link}.",
"privacy.thirdParties.li1Link": "pagina Over",
"privacy.thirdParties.li2Label": "Avatarafbeeldingen",
"privacy.thirdParties.li2": "{strong}, geladen van waar het profiel van wie de review schreef naar wijst. Ze worden opgevraagd met een no-referrer-beleid, dus die host komt niet te weten op welke pagina je was.",
"privacy.publishing.title": "Een review publiceren",
"privacy.publishing.p1": "Een review schrijven gebeurt in je browser, niet op onze server. Het event wordt ondertekend door je NIP-07-extensie (Alby, nos2x of iets dergelijks) en vanuit je browser rechtstreeks naar de relays gestuurd. Je privésleutel verlaat de extensie nooit en deze site ziet hem nooit. Wij bouwen geen eigen sleutelbeheer en wij vragen nooit om een sleutel of een seed phrase.",
"privacy.permanent.title": "Alles wat je op Nostr publiceert is openbaar en blijvend",
"privacy.permanent.p1": "Dit is het stuk dat het waard is om twee keer te lezen. Een review is een ondertekend event op publieke relays. Eenmaal weg is weg:",
"privacy.permanent.li1": "Iedereen kan hem lezen, nu en later, met elke Nostr-client. Hij is niet \"openbaar op Cashumints.space\", hij is openbaar op het netwerk.",
"privacy.permanent.li2": "Hij is voorgoed gekoppeld aan jouw publieke Nostr-sleutel, naast al het andere dat die sleutel ooit gepubliceerd heeft.",
"privacy.permanent.li3": "Hem verwijderen is een verzoek, geen bevel. Je kunt een verwijderevent publiceren en sommige relays houden zich daaraan, maar een relay mag het negeren en wie het event al gekopieerd had heeft het nog steeds. Beschouw een gepubliceerde review als blijvend.",
"privacy.permanent.li4": "Wij kunnen hem niet voor je verwijderen. Wij kunnen hem hier niet meer tonen, en dat verandert niets op de relays.",
"privacy.permanent.p2": "Wil je een mint reviewen zonder dat het aan je hoofdidentiteit vastzit, gebruik dan een aparte Nostr-sleutel. Let wel: bij harde reviews wordt vermeld als een sleutel verder geen activiteit heeft, want een gloednieuwe sleutel die een review van één ster schrijft is een patroon dat lezers verdienen te zien.",
"privacy.contact.title": "Vragen",
"privacy.contact.p1": "De site is open source: wil je iets van het bovenstaande nagaan, lees dan de code op {github}. Vragen en correcties kunnen daar in een issue.",
"disclaimer.title": "Disclaimer · Cashumints.space",
"disclaimer.description": "Wat je moet weten voordat je een Cashu mint gebruikt: mints zijn custodiaal, een mint kan er met jouw geld vandoor gaan, reviews zijn meningen en online betekent bereikbaar, niet betrouwbaar.",
"disclaimer.heading": "Disclaimer",
"disclaimer.lede": "Lees dit voordat je geld in een mint stopt. Laatst bijgewerkt op {date}.",
"disclaimer.callout": "Een Cashu mint houdt jouw geld voor je vast. Verdwijnt hij, dan is je ecash weg, en is er niemand bij wie je kunt aankloppen. Bewaar er alleen wat je kunt missen.",
"disclaimer.custodial.title": "Mints zijn custodiaal",
"disclaimer.custodial.p1": "Als je ecash mint geef je bitcoin aan de beheerder van de mint en krijg je tokens terug. Die tokens zijn een aanspraak op die beheerder. Het is geen bitcoin die jij vasthoudt, en de beheerder is degene die beslist of hij ze honoreert.",
"disclaimer.custodial.p2": "Dat betekent dat elke mint dezelfde risico's draagt: de beheerder kan verdwijnen, kan gedwongen worden te stoppen, kan het geld kwijtraken door een bug of een hack, of kan de server gewoon uitzetten. Er is geen depositogarantie, geen terugboeking, geen helpdesk en geen juridische weg waar je op kunt rekenen. Dit geldt ook voor de best beoordeelde mint op deze site.",
"disclaimer.custodial.p3": "Cashu is bedoeld voor kleine bedragen die snel bewegen. Gebruik het zo: bewaar wat je op het punt staat uit te geven, niet je spaargeld, en spreid over meerdere mints in plaats van alles bij één te parkeren.",
"disclaimer.reviews.title": "Reviews zijn meningen, geen gecontroleerde feiten",
"disclaimer.reviews.p1": "Elke review hier is geschreven door iemand op Nostr en ondertekend met diens sleutel. Ondertekend betekent dat we je kunnen vertellen welke sleutel hem schreef. Het betekent niet dat de review klopt, dat wie hem schreef de mint gebruikt heeft, of dat het niet de beheerder is, een concurrent, of één persoon met tien sleutels.",
"disclaimer.reviews.p2": "Wij controleren niemand die een review schrijft, en wij bewerken of verwijderen geen reviews waar we het niet mee eens zijn. Lees er een paar, kijk wat er werkelijk staat, en weeg een mint met drie reviews anders dan een met driehonderd.",
"disclaimer.status.title": "Online betekent bereikbaar, niet betrouwbaar",
"disclaimer.status.p1": "We controleren elke mint regelmatig en laten zien of hij antwoordde. Meer betekent een groene status niet: de server gaf antwoord. Het zegt niets over of de beheerder solvabel is, eerlijk is, of morgen nog van plan is jouw tokens te honoreren.",
"disclaimer.status.p2": "Een mint kan online zijn tot precies het moment dat hij het niet meer is. De uptime-geschiedenis, de ondersteunde NUTs en de softwareversie zeggen iets over hoe een mint gerund wordt, maar geen van drieën is een belofte over jouw geld.",
"disclaimer.ranking.title": "Hoe de rangschikking werkt, en wat het niet betekent",
"disclaimer.ranking.p1": "Mints worden geordend door een algoritme over publieke reviewgegevens: beoordeling, aantal reviews en recente activiteit, met online mints standaard boven offline mints. De details staan in de open source code.",
"disclaimer.ranking.p2": "Niemand betaalt voor een plek. Er is geen gesponsorde plaats, geen uitgelichte vermelding en geen afspraak met welke mint op deze site dan ook. Hoog staan betekent dat het algoritme de publieke gegevens goed vond, meer niet. Het is geen aanbeveling en het is geen veiligheidsoordeel.",
"disclaimer.notAdvice.title": "Dit is geen financieel advies",
"disclaimer.notAdvice.p1": "Niets op deze site is financieel, beleggings-, juridisch of fiscaal advies. Wij kennen jouw situatie niet en wij adviseren je niet. Of je een mint gebruikt, en welke, is jouw beslissing.",
"disclaimer.dyor.title": "Zoek het zelf uit",
"disclaimer.dyor.li1": "Zoek uit wie hem beheert. Een mint van iemand met een publieke reputatie te verliezen is iets anders dan een anonieme URL.",
"disclaimer.dyor.li2": "Lees de voorwaarden van de mint zelf en zijn bericht van de dag. Een mint die zichzelf een test of een beta noemt meent dat.",
"disclaimer.dyor.li3": "Begin klein. Mint een beetje, geef het uit, melt het er weer uit, en kijk wat er gebeurt voordat je hem meer toevertrouwt.",
"disclaimer.dyor.li4": "Let op de limieten. Een laag maximum is vaak een teken van een voorzichtige beheerder, en een hoog maximum is geen teken dat het veilig is.",
"disclaimer.dyor.li5": "Maak een back-up van je tokens, en onthoud dat een token een waardepapier aan toonder is: wie het heeft, kan het uitgeven.",
"disclaimer.site.title": "Over de site zelf",
"disclaimer.site.p1": "De gegevens hier kunnen fout of verouderd zijn. Mints worden volgens een schema gecontroleerd, relays antwoorden soms niet, en de pagina die je leest is ergens in het verleden gebouwd. De site wordt geleverd zoals hij is, zonder garantie. Lees de {link}.",
"disclaimer.site.link": "gebruiksvoorwaarden",
"disclaimer.updated": "20 augustus 2026",
"login.getPrimal": "Primal downloaden",
"reviews.dialog.identity.extensionGone": "Je browserextensie antwoordt niet. Log opnieuw in, of gebruik een signer op afstand.",
"seo.orgDescription": "Een overzicht van Cashu-mints, met de actuele status van elke mint en reviews uit de gemeenschap, ondertekend op Nostr.",
"seo.ogImageAlt": "Cashumints.space: vind een Cashu-mint die je kunt vertrouwen.",
"nav.home": "Home",
"mint.description.fallbackDomain": "Een Cashu-mint die draait op {domain}.",
"mint.more.title": "Meer mints",
"mint.more.all": "Bekijk alle mints"
}
+80
View File
@@ -0,0 +1,80 @@
/**
* The `getStaticPaths` half of path-prefix routing.
*
* Every page lives under `src/pages/[...locale]/`, and a rest parameter of `undefined`
* emits the bare path. So one file emits `/mints`, `/es/mints` and `/nl/mints`, and
* there is exactly one copy of each page's markup, translated by `t()` rather than by
* being written out three times. Astro's own i18n config knows the same locale list and
* resolves `Astro.currentLocale` from the URL, but it does not generate pages, which is
* what this does.
*
* ~55 mints plus 9 static routes, times three locales, is around 190 prerendered pages.
* The whole set builds in one pass off one API fetch, so the cost is disk, not time.
*/
import type { AstroGlobal } from 'astro';
import { DEFAULT_LOCALE, LOCALES, isLocale, type Locale } from './config.js';
import { splitLocale } from './routing.js';
/** The rest-parameter value for a locale: `undefined` for English, the code otherwise. */
export function localeParam(locale: Locale): string | undefined {
return locale === DEFAULT_LOCALE ? undefined : locale;
}
/**
* One entry per locale, for a page with no other dynamic segment.
*
* The locale is passed through props as well as params so a page can read it without
* re-deriving it from a parameter that is `undefined` for English.
*/
export function localePaths(): Array<{
params: { locale: string | undefined };
props: { locale: Locale };
}> {
return LOCALES.map(({ code }) => ({
params: { locale: localeParam(code) },
props: { locale: code },
}));
}
/**
* The cross product of every locale with some other set of routes.
*
* Used by the mint pages: one entry per mint per locale, each carrying the mint detail
* the page renders from, fetched once and shared across the three locales rather than
* fetched three times.
*/
export function localePathsFor<T extends Record<string, unknown>>(
items: Array<{ params: Record<string, string>; props: T }>,
): Array<{ params: Record<string, string | undefined>; props: T & { locale: Locale } }> {
return LOCALES.flatMap(({ code }) =>
items.map((item) => ({
params: { ...item.params, locale: localeParam(code) },
props: { ...item.props, locale: code },
})),
);
}
/**
* The locale and the unprefixed path for the page being rendered.
*
* Derived from the URL rather than from props, so a layout or a component deep in the
* tree can ask without every page above it threading two more props through. The path
* it returns is the key the whole hreflang set is built from: `/mint/kashu.me`, the
* same string for all three locales, which is exactly what makes them alternates of
* each other.
*/
export function pageLocale(astro: AstroGlobal): { locale: Locale; path: string } {
const fromUrl = splitLocale(astro.url.pathname);
/*
* In dev, Astro serves the route before `getStaticPaths` props are in hand for some
* requests, and `Astro.params.locale` is the more direct answer when it is set.
* They agree in the build; preferring the param means dev cannot disagree with it.
*/
const param = astro.params['locale'];
if (typeof param === 'string' && isLocale(param)) {
return { locale: param, path: fromUrl.path };
}
return fromUrl;
}
+107
View File
@@ -0,0 +1,107 @@
/**
* Path arithmetic for path-prefix locales with the default language at the root.
*
* `/mints` is English, `/es/mints` is Spanish, `/nl/mints` is Dutch. Route slugs stay
* English in every language: `/es/mints`, never `/es/mentas`. That is a deliberate
* trade. Translated slugs would read marginally better in a browser's address bar and
* would cost a permanent, growing table of slug-per-route-per-language that every
* internal link, the link checker, the sitemap and the language switcher would have to
* agree on, forever. Stable URLs are worth more here than translated ones, and the
* hreflang set is what actually tells a crawler these pages are the same page.
*/
import { DEFAULT_LOCALE, LOCALE_CODES, isLocale, type Locale } from './config.js';
/**
* The locale a path is in, and the path with its prefix removed.
*
* `/es/mint/kashu.me` is `{ locale: 'es', path: '/mint/kashu.me' }`. An unprefixed path
* is the default locale. The path always starts with `/` and never ends with one,
* except for the root, which is exactly `/`.
*/
export function splitLocale(pathname: string): { locale: Locale; path: string } {
const clean = normalise(pathname);
const segments = clean.split('/').filter(Boolean);
const first = segments[0];
if (first !== undefined && isLocale(first) && first !== DEFAULT_LOCALE) {
const rest = segments.slice(1).join('/');
return { locale: first, path: rest ? `/${rest}` : '/' };
}
return { locale: DEFAULT_LOCALE, path: clean };
}
/** Just the locale, for a page that only needs to know which language it is. */
export function localeFromPath(pathname: string): Locale {
return splitLocale(pathname).locale;
}
/** Just the unprefixed path, which is the key a page's translations are keyed by. */
export function stripLocale(pathname: string): string {
return splitLocale(pathname).path;
}
/**
* The same page in another language.
*
* `localePath('/mint/kashu.me', 'nl')` is `/nl/mint/kashu.me`. The default locale keeps
* the bare path, which is the whole point of `prefixDefaultLocale: false`.
*/
export function localePath(path: string, locale: Locale): string {
const clean = normalise(path);
if (locale === DEFAULT_LOCALE) return clean;
return clean === '/' ? `/${locale}` : `/${locale}${clean}`;
}
/**
* Absolute URL for one locale's version of a page.
*
* hreflang and canonical both require absolute URLs, and a relative one there is not a
* lesser signal, it is an ignored one.
*/
export function localeUrl(path: string, locale: Locale, site: URL | string): string {
const origin = typeof site === 'string' ? new URL(site).origin : site.origin;
return origin + localePath(path, locale);
}
export interface Alternate {
/** The `hreflang` value: a locale code, or `x-default`. */
hreflang: string;
href: string;
locale: Locale;
}
/**
* The complete reciprocal alternate set for one page, x-default included.
*
* Every locale's version of this page lists every locale's version of this page,
* itself among them, plus one `x-default` pointing at English. That reciprocity is the
* part crawlers actually check: a page that lists alternates which do not list it back
* has its whole set discarded, so a partial set is worth less than none.
*
* `x-default` is the English URL rather than a language selector, because there is no
* selector page here and English is what an unmatched visitor gets.
*/
export function alternates(path: string, site: URL | string): Alternate[] {
const out: Alternate[] = LOCALE_CODES.map((locale) => ({
hreflang: locale,
href: localeUrl(path, locale, site),
locale,
}));
out.push({
hreflang: 'x-default',
href: localeUrl(path, DEFAULT_LOCALE, site),
locale: DEFAULT_LOCALE,
});
return out;
}
/** Leading slash, no trailing slash, no double slashes, no query or hash. */
function normalise(pathname: string): string {
const withoutQuery = pathname.split(/[?#]/)[0] ?? '';
const trimmed = withoutQuery.replace(/\/+$/, '').replace(/\/{2,}/g, '/');
if (trimmed === '') return '/';
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
}

Some files were not shown because too many files have changed in this diff Show More