Skip to content

GraveMint SDK

@solanadeads/gravemint · version 1.2.1 · install with npm i @solanadeads/gravemint

This page is generated from the published tarball — it is byte-for-byte the README inside node_modules/@solanadeads/gravemint at v1.2.1. It is not a summary and it cannot drift.

The official SDK for GraveMint — put a real, complete mint page on your own site, using our backend for all of it.

Not a widget and not an iframe: you get the data, you render it however you like, and the collector never leaves your domain.

  • Zero runtime dependencies. Native fetch. Node 18+, browsers, Bun, Deno.
  • No chain SDK bundled. You bring your own wallet library, so this cannot collide with whatever you already use.
  • Dual build. ESM + CJS. Full .d.ts.
  • Versioned contract. v1 is additive-only — see Versioning.
bash
npm install @solanadeads/gravemint

Published. npm install @solanadeads/gravemint gives you the mint-capable generation documented below — gm.v1 and gm.mint included. Partner API keys are invite-only: ask us for one, then see Getting a key.


The one rule worth reading first

You sign. We broadcast.

prepare() gives you a transaction GraveMint built. Your wallet signs it. The signed bytes go back to execute(), and GraveMint submits them.

This is not stylistic. Before broadcasting, GraveMint verifies the signed transaction is byte-for-byte the one it prepared — recipient, amount and instructions all pinned. That check only means anything if GraveMint is the one broadcasting. If your page submitted the transaction itself, the guard would be bypassed and your page would become the thing deciding what lands on chain.

There is deliberately no submit method in this SDK. If you find yourself wanting one, you are about to remove the protection that makes the prepared transaction trustworthy.


Contents


Quick start

ts
import { GraveMintClient } from "@solanadeads/gravemint";

const gm = new GraveMintClient({ apiKey: "gm_pub_..." });

// 1. Everything needed to render the drop, in one call.
const drop = await gm.v1.collection("deads");

console.log(drop.collection.name);
console.log(drop.stats.mintedCount, "/", drop.stats.totalSupply);

// 2. Which phase is live right now (server-decided, not client-computed).
const phase = drop.phases.active[0];

// 3. Can this wallet mint it?
const verdict = await gm.v1.eligibility(drop.collection.id, phase.id, wallet);

// 4. Mint. You supply the signer; GraveMint broadcasts.
const result = await gm.mint.mint({
  collectionId: drop.collection.id,
  phaseId: phase.id,
  quantity: 1,
  walletAddress: wallet,
  signer,
});

Getting a key

Partner keys are issued by GraveMint, not self-service. There is no signup page — the surface is invite-only while it beds in. Talk to us and we will issue one scoped to your collections and your domains.

Two classes, and the difference matters:

ClassPrefixWhere it belongsWhat bounds it
Publishablegm_pub_your web page — it is meant to be visiblethe origin allow-list on the key
Secretgm_live_your server onlysecrecy — it has no origin binding

A publishable key is public by construction; treating it as a secret is the wrong mental model. What protects it is that it only works from origins registered on the key itself. So the important thing is not hiding it — it is telling us every origin you serve from, including staging and preview domains. A missing origin is a 403 that looks like an outage.

Never put a gm_live_ key in browser code. It has no origin binding, so a leak is unbounded until it is revoked.

⚠️ The converse also bites: a gm_pub_ key does NOT work server-side. It is admitted by matching the request's Origin against the key's allow-list, and a Node process sends no Origin — so a publishable key used from a server is refused with ORIGIN_NOT_ALLOWED, every time, with no way to satisfy it. Server calls need gm_live_.


Configuration

ts
new GraveMintClient({
  apiKey:     "gm_pub_...",   // required
  timeoutMs:  30_000,
  maxRetries: 3,              // 429 / 5xx / network
  rateLimit:  { requestsPerSecond: 5, burst: 10 },   // null to disable
  fetch:      globalThis.fetch,
});

Base URLs — there are two, on purpose

The versioned contract is a separate mount from the legacy public read API, with its own auth and its own middleware. The client keeps two base URLs:

defaultused by
baseUrlhttps://api.solanadeads.com/gravemint/publicthe legacy read API
v1BaseUrlhttps://api.solanadeads.com/gravemint/v1gm.v1, gm.mint

You normally set neither. If you set baseUrl to something ending in /public, v1BaseUrl is derived for you. If it ends in anything else — because you are routing through your own proxy — the constructor throws and asks you to pass v1BaseUrl explicitly.

That is deliberate. Guessing would surface as a 404 for a collector mid-mint; throwing surfaces at startup, for you.

ts
// Proxying through your own backend:
new GraveMintClient({ apiKey, v1BaseUrl: "https://yoursite.com/api/gravemint" });

Node <18 needs a fetch polyfill: new GraveMintClient({ apiKey, fetch }).


Building a mint page

Most of a mint page is reading, not minting. Live GraveMint collections average 5 phases (the largest has 38), and about a quarter gate eligibility. A client that can only mint cannot render two-thirds of drops.

gm.v1.collection(identifier) returns all of it in one call. identifier is a shortId, a collection UUID, or an on-chain address.

ts
const drop = await gm.v1.collection("deads");

drop.collection    // name, symbol, image, chain, socials, isVerified
drop.stats         // totalSupply, mintedCount, availableCount, percentMinted
drop.phases        // { all, active, upcoming }
drop.capabilities  // can you render this drop faithfully?
drop.serverTime    // our clock — see below

The rule that keeps you correct

Every value here is a server verdict. Do not recompute any of them.

That is not a style preference. Pricing, eligibility and phase state each depend on data that is deliberately not sent to you, so a client that re-derives them will disagree with what actually happens at mint time — and disagree silently.

Phases and countdowns

ts
for (const phase of drop.phases.all) {
  phase.status            // "active" | "upcoming" | "ended"
  phase.name
  phase.startDate         // ISO, or null
  phase.endDate
  phase.maxPerWallet      // null = no cap
  phase.maxPerTransaction
  phase.phaseSupply
  phase.isGated           // does it have eligibility requirements?
  phase.bogo              // { buy, get } or null
  phase.msUntilStart      // ← render countdowns from these
  phase.msUntilEnd
}

Two things people get wrong:

phases.all includes ended phases. That is intentional — a schedule that silently omits what has closed makes a multi-phase drop look shorter than it is. Filter if you do not want them.

Do not count down with Date.now(). Use msUntilStart / msUntilEnd, which we derive from our clock. A viewer's device clock can be minutes out, and "starts in −3s" on a phase that never opens is worse than no countdown. If you need a ticking display, anchor once against drop.serverTime and tick locally.

Price is a shape, not a number

ts
type PriceDisplay =
  | { kind: "amount"; amount: number; currency: string; isFree: boolean; approximate?: boolean }
  | { kind: "range";  min: number; max: number; currency: string }
  | { kind: "hidden" }
  | { kind: "unknown" };

Render it by branching, and treat all four as normal:

tsx
switch (phase.priceDisplay.kind) {
  case "amount":
    return phase.priceDisplay.isFree
      ? <>Free</>
      : <>{phase.priceDisplay.approximate ? "~" : ""}{phase.priceDisplay.amount} {phase.priceDisplay.currency}</>;
  case "range":
    return <>{phase.priceDisplay.min}–{phase.priceDisplay.max} {phase.priceDisplay.currency}</>;
  case "hidden":
    return <>Price revealed when you qualify</>;
  case "unknown":
    return <>Price shown at checkout</>;
}

Why it is not just a number. GraveMint's price resolver runs ten stages where later ones overwrite earlier ones — base price, pack tier, native peg, USD peg, dynamic Dutch, BOGO, generative fee, free-mint bounty, claim code, XP discount. Six of them draw on data no public read returns. A reassembled price will not match what the collector is charged.

hidden and unknown are not errors. About a tenth of live phases deliberately conceal the price until a wallet qualifies — that is the creator's choice and it is enforced on our side, not left to you. And a dynamic-Dutch price genuinely cannot be computed for an anonymous caller.

🚨 Never render hidden or unknown as 0 or "Free". That is a real bug we shipped once in our own embed widget and had to fix. If you find yourself writing price ?? 0, stop.

The authoritative figure — what the collector actually pays — comes back from prepare() as pricing. priceDisplay is for the page before a mint is requested; the real number only exists once the full cascade has run.

Eligibility

ts
const v = await gm.v1.eligibility(collectionId, phaseId, walletAddress);

v.gated              // does this phase gate at all?
v.meetsRequirements  // the verdict
v.onAllowlist
v.spotsAllocated
v.spotsRemaining
v.requirements       // [{ type, met, description }] — render these
v.phase              // { id, name, hasStarted, hasEnded }
v.message

This is server-evaluated because it has to be: the dominant gate is an NFT-holding check needing an on-chain lookup, and an allowlist must never be published to a client.

meetsRequirements deliberately ignores the clock. It answers "will this wallet qualify", with timing reported separately as phase.hasStarted / hasEnded. So an upcoming phase reads as eligible but not yet open rather than "not eligible" — which is what lets you show "you're on the list, opens in 2h" instead of turning qualified collectors away.

ts
const canMintNow = v.meetsRequirements && v.phase.hasStarted && !v.phase.hasEnded;

The rest of the drop — art, feed, bounty, per-wallet counts

These are all scoped to your key the same way collection() is: a key issued for another drop gets 403 COLLECTION_NOT_IN_SCOPE, never an empty list.

ts
await gm.v1.gallery("deads", { limit: 24 });   // the drop's art
await gm.v1.recentlyMinted("deads");           // the live "just minted" feed
await gm.v1.bounty("deads");                   // bounty summary, when there is one
await gm.v1.bountyPrizes("deads");             // its public prize table
await gm.v1.walletMints("deads", wallet);      // "you have minted 2 of 3", per phase

🚨 Do not derive walletMints from eligibility(). The two count differently on purpose: walletMints includes bonus mints (BOGO, bounty) and the eligibility engine excludes them. For any wallet that took one, a figure you compute yourself is wrong in the direction that lets a collector believe they have mints left and get refused at prepare.

Claim codes

17.6% of live drops use them, so an integration that ignores claim codes cannot render one drop in six correctly.

ts
await gm.v1.claimCodes(collectionId);                          // does this drop use codes?
await gm.v1.claimCodeBenefits(collectionId, wallet);           // what has this wallet earned?
await gm.v1.validateClaimCode(collectionId, 'CODE-123');       // { valid, grantType, ... }

🚨 validateClaimCode is scoped and READ-ONLY. It does not redeem the code or consume a use. A code belonging to a different drop answers byte-identically to an unknown code — so it cannot be used to discover that a code exists somewhere else.

Pass the code through to prepare when you mint; the server applies the benefit.

Pricing helpers

ts
await gm.v1.tokenPrices();            // platform token prices, for an SPL-priced drop
await gm.v1.peggedPrice(phaseId);     // resolved amount for a USD/native-pegged phase
await gm.v1.dutchPrice(phaseId);      // live price of a dynamic Dutch phase

⚠️ Never render phase.price for a pegged phase. It is Dutch-resolved but not peg-resolved, so there it is a stale cached token amount — one live phase carries 110000 TOUCHGRASS in that field.

Both phase routes are scoped through the phase's own collection: a phase id is not a way around collection scope.

Supply, traits and social proof

ts
await gm.v1.availability(collectionId);   // mintable now, accounting for held reservations
await gm.v1.traits(identifier);           // trait names and values, for gallery filters
await gm.v1.allMinted(identifier);        // every minted item
await gm.v1.topHolders(identifier);       // largest holders
await gm.v1.topMinters(identifier);       // who minted the most

Capabilities — when you cannot render a drop

ts
drop.capabilities  // { requiresFeatures: string[], supportedBySurface: boolean, mintUrl: string | null }

v1 covers core mint: supply, phases, pricing (including BOGO, pegged prices and hidden prices), eligibility, per-wallet limits, and minting. Some drops are built around mechanics it does not model — packs, gallery selection, generative builders, claim codes, cross-chain payment.

tsx
if (!drop.capabilities.supportedBySurface) {
  return <a href={drop.capabilities.mintUrl}>Mint on GraveMint</a>;
}

Please actually implement this branch. Additive-only versioning stops a pinned client from crashing; it cannot stop one from being quietly wrong, which is worse — a panel that looks fine and misstates the deal, with the collector finding out at checkout or never. supportedBySurface is how a client written today stays correct as new mechanics ship.

The check is a whitelist: a requirement we have not classified makes a drop unsupported rather than optimistically supported. mintUrl is present even when supported, so you can always offer "view the full drop page".


Minting

ts
const prepared = await gm.mint.prepare({
  collectionId, phaseId, quantity: 1, walletAddress,
  affiliateCode,        // optional; validated server-side, ignored if unused
});

const signed = await signer.signTransaction(prepared.transactions[0].transaction);

const result = await gm.mint.execute({
  sessionId: prepared.sessions[0].sessionId,
  signedTransaction: signed,
});

v1 always returns the batch shapetransactions[] and sessions[] — even for quantity: 1. The first-party /gravemint/mint/prepare route rewrites single mints into a flatter legacy shape with a top-level sessionId; that rewrite lives in that route's wrapper, and v1 calls the handler directly. So read sessions[0].sessionId, not sessionId. Arrays for everything is the more honest shape anyway — a pack mint genuinely produces several. gm.mint.mint() handles this for you.

Or in one call:

ts
const result = await gm.mint.mint({
  collectionId, phaseId, quantity: 1, walletAddress, signer,
});

prepare() does the real work — eligibility, limits, pricing, supply reservation, transaction build — and returns pricing (the authoritative breakdown), sessionId and expiresAt.

Sessions expire. Do not hold a signature and submit it later; prepare again. A prepared session also reserves supply, so do not call prepare() just to read a price — that is what priceDisplay is for.

The signer

ts
interface TransactionSigner {
  signTransaction(base64Transaction: string): Promise<string>;
}

One method, base64 in and base64 out. That narrowness is the point: the less this SDK knows about your wallet stack, the fewer ways it breaks when you change it.

A real signer (@solana/wallet-adapter)

ts
import { VersionedTransaction } from "@solana/web3.js";

const signer = {
  async signTransaction(base64: string) {
    const tx = VersionedTransaction.deserialize(Buffer.from(base64, "base64"));
    const signed = await wallet.signTransaction(tx);
    return Buffer.from(signed.serialize()).toString("base64");
  },
};

Most wallet wrappers already expose this shape.

Chain support

v1 is Solana-first, which is where 81% of live collections are and — not coincidentally — where our server-side transaction integrity guarantee is strongest. Drops on chains this surface cannot complete in-app report it through capabilities.supportedBySurface, so check that rather than discovering it at prepare().


Errors and retries

Every error carries a machine-readable code, a human message safe to show a collector, plus .status and .requestId for support.

ts
import { GraveMintError, RateLimitError } from "@solanadeads/gravemint";

try {
  await gm.mint.mint({ ... });
} catch (err) {
  if (err instanceof GraveMintError) {
    console.error(err.code, err.requestId);
    showToast(err.message);      // safe: no internals, no schema details
  }
}

🚨 Read this before branching on a code

v1 returns codes from three different places, and only one set is frozen. Measured against the source, not assumed:

  1. v1's own surface — auth, scope and lookup. Frozen and documented below.

  2. Middleware on the mint path — RATE_LIMITED, COLLECTION_RATE_LIMITED, WALLET_BLOCKED, plus bot-detection codes.

  3. The shared mint handlers, which v1 reuses rather than forks. This set is large, not frozen, and deliberately not enumerated here — three separate mechanisms feed it: a 29-entry registry (apps/api/src/utils/mintErrors.js, sent via sendKnownError), ~40 inline sendCodedError codes, and the per-transaction errorCode values described below. Representative examples: NO_NFTS_AVAILABLE, PHASE_ENDED, WALLET_LIMIT_EXCEEDED, SESSION_NOT_FOUND, INSUFFICIENT_FUNDS, NOT_WHITELISTED.

    🚨 Do not treat any list of these as complete. Four separate attempts to enumerate them while writing this document were each wrong, because each mechanism needs a different search. Some rejections also arrive with a message and no code at all. Branch on the frozen set; display the rest.

🚨 A batch mint reports per-transaction failures somewhere else entirely.execute on a multi-NFT mint returns HTTP 200 with a results array, and each entry carries its own errorCodeTX_MODIFIED, SESSION_EXPIRED, NFT_MISMATCH, SESSION_CORRUPTED and the XRPL accept codes live there, not in the thrown error's code. A client branching only on err.code will never see them:

ts
const res: any = await gm.mint.mint({ ... });
if (res.partial) {
  for (const r of res.results ?? []) {
    if (!r.success) console.warn(r.sessionId, r.errorCode);  // e.g. TX_MODIFIED
  }
}

So: branch on the frozen codes, and treat everything else as a displayable message. Do not assume a code exists.

ts
const code = err instanceof GraveMintError ? err.code : undefined;
if (code === "TX_MODIFIED") { /* never retry — see below */ }
else if (code === "ORIGIN_NOT_ALLOWED") { /* config: send us the origin */ }
else { showToast(err.message); }   // covers the unfrozen and code-less cases

Frozen — v1's own codes

CodeMeaningRetry?
COLLECTION_NOT_FOUNDno such drop, or not publicly servableno
LOOKUP_FAILEDwe could not read our own datayes, backoff
COLLECTION_REQUIREDthe request named no collectionno — fix the call
CODE_REQUIREDa claim-code check arrived with no codeno — fix the call
API_KEY_REQUIRED / API_KEY_INVALIDmissing or wrong keyno — fix config
API_KEY_INACTIVE / API_KEY_EXPIREDkey revoked or lapsedno — talk to us
ORIGIN_NOT_ALLOWEDvalid key, unregistered origin (or a gm_pub_ key used server-side)no
COLLECTION_NOT_IN_SCOPEvalid key, not issued for this dropno
AUTH_UNAVAILABLEwe could not check your key — we fail closedyes, backoff

Also frozen, and emitted by the shared mint handlers: COLLECTION_CLOSED · NFTS_LOCKED · SESSION_EXPIRED · TX_MODIFIED.

🚨 WHERE a mint error appears depends on the quantity. For a single NFT the server promotes the per-transaction code to the top level (sendCodedError(res, result.error, 400, result.errorCode || 'MINT_FAILED')), so err.code === 'TX_MODIFIED' works. For a batch the codes stay inside results[].errorCode and err.code is never set. Handle both — and note that any quantity above 1 is a batch, because every Solana standard mints one NFT per transaction.

🔒 You sign; WE submit. v1 will not accept a transaction you have already broadcast yourself — a transaction we never inspected is one whose recipient, amount and instructions we never verified. Two codes enforce that, and both are TERMINAL (retrying the same body cannot succeed):

  • CLIENT_BROADCAST_NOT_ALLOWED — the request carried transactionHash (or txHash / transaction_hash). Sign the transaction prepare-mint returned and send it back as signedTransaction; we submit it and confirm it.
  • CHAIN_NOT_SUPPORTED_BY_SURFACE — the drop is on a chain this API does not serve. v1 is Solana-only. This is distinct from CHAIN_DISABLED, which means a chain was retired platform-wide; your drop's chain is fine, it is just out of scope here. Use mintUrl from the collection response to send the collector to GraveMint instead.

Frozen but not emitted on the v1 path: CHAIN_DISABLED · UNSUPPORTED_BY_SURFACE (not emitted anywhere yet), and NOT_ELIGIBLE · WALLET_MISMATCH (emitted elsewhere in GraveMint, on the cross-chain and mint-page-token paths — neither of which v1 mounts). They will not be renamed.

The eligibility endpoint

gm.v1.eligibility() reuses the first-party handler, so it has its own two codes. Neither is frozen:

CodeMeaningRetry?
INVALID_ADDRESSthe wallet address is not valid for this chainno — fix the call
CHECK_BUSYthe eligibility engine is at its global budgetyes, short backoff

Rate limiting and suspension

CodeMeaningRetry?
RATE_LIMITEDtoo many requests for this keyyes, honour Retry-After
COLLECTION_RATE_LIMITEDtoo many prepares against this collection, from everyoneyes, honour retryAfter
WALLET_BLOCKEDthis wallet is suspendedno — on prepare-mint the body also carries reason / blockedUntil / permanent; on execute-mint it is the code and message only
ASSET_BANNEDthis collection or token is on the platform ban listno

🚨 Never retry TX_MODIFIED. It means the bytes we were asked to broadcast differ from the bytes we built — different recipient, different amount, added instructions. Re-submitting the same bytes cannot succeed and should not. Treat the attempt as fatal, discard the session, and start over with a fresh prepare(). If it recurs, something is modifying transactions between us and the wallet, and that is worth telling us about.

ORIGIN_NOT_ALLOWED and API_KEY_REQUIRED are kept distinct on purpose so you can tell "I forgot my key" from "my domain is not registered" — the second is a one-line fix on our side.

The SDK retries 429/5xx/network automatically with exponential backoff, honouring Retry-After, up to maxRetries. It does not auto-retry the terminal codes above.

Rate limits

LimitValueScope
Reads on /v1 (shared with /gravemint/public)600 / minuteper IP
/v1/prepare-mint and /v1/execute-mint (shared bucket)60 / minuteper key
prepare-mint, per COLLECTION30 / 10sper collection, shared with first-party traffic
SDK client-side limiter5 rps sustained, 10 burstyour process

These are floors we publish, not ceilings we reserve: we will not lower them without telling you first, because a pinned client cannot be asked to redeploy. Raising them is not a breaking change.

Two sharing details that matter in practice:

  • prepare and execute share the same 60/min bucket, and a mint needs one of each — so the sustained ceiling is roughly 30 mints per minute per key, not 60.
  • The read limiter is the same instance /gravemint/public uses, so v1 reads and first-party public reads from one IP draw on one 600/min bucket.
  • 🚨 The per-collection prepare cap is shared with gravemint.io. It is keyed on the collection alone, so on a hot drop your prepares compete with every first-party collector's — and on a busy launch it can bind well below the 60/min-per-key figure. Treat 60/min as a ceiling you may not reach, not a reservation.

The mint limit is keyed on the API key, not the IP, so a partner proxying every collector through one server is not sharing a bucket with the rest of the internet — but it does mean all of that partner's collectors share it.

On a 429 the SDK honours Retry-After and retries up to maxRetries. Disable the client-side limiter with rateLimit: null only if you have your own queue. If you need more headroom, ask — do not engineer around it.


Versioning

v1 is additive-only. Within the major we will not remove a field, change a field's type, rename an error code, or tighten validation. New behaviour arrives as new fields or a new version. Unversioned legacy paths keep working.

You can pin this package and leave it alone. That is the whole promise, and it is why capability negotiation exists — pinning keeps you working, and supportedBySurface keeps you correct.

ts
await gm.v1.version();   // { version: "v1", stability: "additive-only", docs }

x-gm-client is sent on every request so we can see which SDK versions are in use before we change anything. (It is not User-Agent: browsers forbid setting that header, so it would silently vanish in exactly the tier we most need to measure.)

Deprecations will be announced with a migration path and a real timeline, and old majors keep working while both are live.


Legacy read API

The original read-only surface is unchanged and still supported:

ResourceMethods
gm.discoverygetFeatured, getLiveMints, getTrending, getUpcoming, getRecentSoldouts, listCollections, searchCollections
gm.collectionsget, getGallery, getRecentlyMinted
gm.nftsget
gm.bountygetSummary, getPrizes
gm.platformgetCapabilities, getTokenPrices
gm.codesvalidate (read-only — does not redeem)

⚠️ These are not usable with a partner key, and mostly you do not want them. /gravemint/public sits behind the first-party origin check, so a partner origin is refused and the only origin-less credential it accepts is the shared platform secret, which partners are never issued.

The split is deliberate rather than incidental:

  • gm.discovery stays first-party. Featured, trending, upcoming, search and listCollections are cross-collection by definition. A key issued for one drop enumerating every drop on the platform is exactly what the scoping model exists to prevent, so this is not a gap and will not be opened up.
  • Everything else a partner needs now has a scoped gm.v1 equivalent (gravemint#3348) — gallery, recentlyMinted, bounty, bountyPrizes and walletMints above, plus collection and eligibility. Use those.

Use gm.v1 to build a mint page regardless: gm.collections.get() returns raw phase fields including prices that have not been through the resolver, so rendering a price from it can leak one a creator chose to hide.


Security notes for partners

  • Register every origin, including staging and preview URLs. Origin binding is what makes a publishable key safe.
  • gm_live_ never reaches a browser. No origin bounds it.
  • If a key leaks, tell us and we revoke it. Revocation is immediate, but it does not undo mints already made with it — we can tell you exactly what those were, because every mint records the key that made it.
  • Never recompute a price or an eligibility verdict. Beyond being wrong, it moves a trust decision onto a surface we cannot verify.
  • Never broadcast a transaction yourself. See the top of this document.

Development

bash
npm test          # unit tests, no network
npm run typecheck
npm run build     # dual ESM + CJS

License

MIT

SDK pages are generated from the published npm tarballs and cannot drift.