@feralfile/cli 2.0.2 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,443 +0,0 @@
1
- "use strict";
2
- /**
3
- * Marketplace URL parser for `ff-cli find`.
4
- *
5
- * Accepts user input in any of these forms and returns a normalized result:
6
- * - Marketplace token URL (Objkt, fxhash, Art Blocks, OpenSea, SuperRare, Feral File, Neort, Verse)
7
- * - Raw on-chain coordinates: "ethereum:0xabc...:123" / "tezos:KT1...:456"
8
- * - Wallet address: 0x{40-hex} (Ethereum) or tz1/tz2/tz3{33} (Tezos)
9
- *
10
- * The unified entry point is {@link parseFindInput}. Per-marketplace parsers
11
- * are exposed for unit testing.
12
- *
13
- * Series URLs (fxhash `/generative/...`, Feral File `/artworks/...`,
14
- * Art Blocks `/projects/...`) are recognized but not resolved in v1 — they
15
- * require a per-marketplace API call to derive a representative token. The
16
- * parser returns an `unsupported` result with a clear reason so the caller
17
- * can surface guidance instead of a generic "could not parse" error.
18
- */
19
- Object.defineProperty(exports, "__esModule", { value: true });
20
- exports.parseFindInput = parseFindInput;
21
- exports.parseMarketplaceUrl = parseMarketplaceUrl;
22
- exports.parseObjkt = parseObjkt;
23
- exports.parseArtBlocks = parseArtBlocks;
24
- exports.parseFxhash = parseFxhash;
25
- exports.parseOpenSea = parseOpenSea;
26
- exports.parseSuperRare = parseSuperRare;
27
- exports.parseNeort = parseNeort;
28
- exports.parseVerse = parseVerse;
29
- exports.parseRaster = parseRaster;
30
- exports.parseFeralFile = parseFeralFile;
31
- const ETH_ADDR = /^0x[a-fA-F0-9]{40}$/;
32
- const TEZOS_ADDR = /^(tz1|tz2|tz3)[1-9A-HJ-NP-Za-km-z]{33}$/;
33
- const RAW_COORDS = /^(ethereum|tezos):([^:]+):([^:]+)$/i;
34
- /**
35
- * Parse any user-supplied input for the `ff-cli find` command.
36
- *
37
- * Returns:
38
- * - `{ kind: 'token', ... }` — successfully extracted on-chain coordinates
39
- * - `{ kind: 'address', ... }` — wallet/artist address (for the artist path)
40
- * - `{ kind: 'unsupported', reason }` — recognized but cannot extract a token
41
- * - `null` — input does not match any recognized form; caller may show help
42
- */
43
- function parseFindInput(input) {
44
- const trimmed = input.trim();
45
- if (!trimmed) {
46
- return null;
47
- }
48
- if (ETH_ADDR.test(trimmed)) {
49
- return { kind: 'address', chain: 'ethereum', address: trimmed.toLowerCase() };
50
- }
51
- if (TEZOS_ADDR.test(trimmed)) {
52
- return { kind: 'address', chain: 'tezos', address: trimmed };
53
- }
54
- const rawMatch = RAW_COORDS.exec(trimmed);
55
- if (rawMatch) {
56
- return {
57
- kind: 'token',
58
- source: 'raw',
59
- coords: {
60
- chain: rawMatch[1].toLowerCase(),
61
- contract: normalizeContract(rawMatch[2]),
62
- tokenId: rawMatch[3],
63
- },
64
- };
65
- }
66
- let parsedUrl;
67
- try {
68
- parsedUrl = new URL(trimmed);
69
- }
70
- catch {
71
- return null;
72
- }
73
- return parseMarketplaceUrl(parsedUrl);
74
- }
75
- /**
76
- * Dispatch a URL to the per-host parser. Returns `null` when the host is
77
- * not one of the supported sources.
78
- */
79
- function parseMarketplaceUrl(url) {
80
- const host = url.hostname.toLowerCase().replace(/^www\./, '');
81
- if (host === 'objkt.com' || host.endsWith('.objkt.com')) {
82
- return parseObjkt(url);
83
- }
84
- if (host === 'artblocks.io' || host.endsWith('.artblocks.io')) {
85
- return parseArtBlocks(url);
86
- }
87
- if (host === 'fxhash.xyz' || host.endsWith('.fxhash.xyz')) {
88
- return parseFxhash(url);
89
- }
90
- if (host === 'feralfile.com' || host.endsWith('.feralfile.com')) {
91
- return parseFeralFile(url);
92
- }
93
- if (host === 'opensea.io' || host.endsWith('.opensea.io')) {
94
- return parseOpenSea(url);
95
- }
96
- if (host === 'superrare.com' || host.endsWith('.superrare.com')) {
97
- return parseSuperRare(url);
98
- }
99
- if (host === 'neort.io' || host.endsWith('.neort.io')) {
100
- return parseNeort(url);
101
- }
102
- if (host === 'verse.works' || host.endsWith('.verse.works')) {
103
- return parseVerse(url);
104
- }
105
- if (host === 'raster.art' || host.endsWith('.raster.art')) {
106
- return parseRaster(url);
107
- }
108
- return null;
109
- }
110
- /**
111
- * Lowercase Ethereum contracts; leave Tezos KT1 contracts case-sensitive
112
- * (base58 prefix is case-significant on Tezos).
113
- */
114
- function normalizeContract(contract) {
115
- return contract.startsWith('0x') ? contract.toLowerCase() : contract;
116
- }
117
- /**
118
- * Objkt URL forms:
119
- * objkt.com/tokens/{KT1...}/{tokenId} — raw FA contract
120
- * objkt.com/tokens/{alias}/{tokenId} — collection alias (e.g. "hicetnunc")
121
- * objkt.com/asset/{KT1...}/{tokenId} — legacy redirect
122
- *
123
- * Aliases are resolved to KT1 contracts via
124
- * {@link ./objkt-marketplace#resolveObjktAlias} (separate file because the
125
- * lookup is async).
126
- */
127
- function parseObjkt(url) {
128
- const direct = /^\/(?:tokens|asset)\/(KT[A-Za-z0-9]+)\/(\d+)\/?$/.exec(url.pathname);
129
- if (direct) {
130
- return {
131
- kind: 'token',
132
- source: 'objkt',
133
- coords: { chain: 'tezos', contract: direct[1], tokenId: direct[2] },
134
- };
135
- }
136
- const alias = /^\/(?:tokens|asset)\/([a-zA-Z][a-zA-Z0-9_-]*)\/(\d+)\/?$/.exec(url.pathname);
137
- if (alias) {
138
- return { kind: 'objkt-alias', alias: alias[1], tokenId: alias[2] };
139
- }
140
- if (url.pathname.startsWith('/collections/') || url.pathname.startsWith('/collection/')) {
141
- return {
142
- kind: 'unsupported',
143
- reason: 'Objkt collection URLs are not yet supported in v1. Paste a specific token URL ' +
144
- '(objkt.com/tokens/{contract-or-alias}/{tokenId}) or use `tezos:{contract}:{tokenId}`.',
145
- };
146
- }
147
- return {
148
- kind: 'unsupported',
149
- reason: `Objkt URL not recognized: ${url.pathname}. Expected /tokens/{contract-or-alias}/{tokenId}.`,
150
- };
151
- }
152
- /**
153
- * Art Blocks URL forms:
154
- * artblocks.io/token/{0xcontract}-{tokenId} — direct token
155
- * artblocks.io/marketplace/collections/{0xcontract}/tokens/{id} — marketplace UI token
156
- * artblocks.io/collection/{slug} — collection (series)
157
- * artblocks.io/collections/{slug} — plural; 307→singular
158
- *
159
- * Collection slugs are resolved to (contract, first_tokenId) via
160
- * {@link ./ab-marketplace#resolveArtBlocksCollection} (one Hasura GraphQL
161
- * call). `/projects/{id}` legacy URLs return 404 on artblocks.io and are
162
- * surfaced as a clear unsupported message.
163
- */
164
- function parseArtBlocks(url) {
165
- let m = /^\/token\/(0x[a-fA-F0-9]{40})-(\d+)\/?$/.exec(url.pathname);
166
- if (m) {
167
- return {
168
- kind: 'token',
169
- source: 'artblocks',
170
- coords: { chain: 'ethereum', contract: m[1].toLowerCase(), tokenId: m[2] },
171
- };
172
- }
173
- m = /^\/marketplace\/collections\/(0x[a-fA-F0-9]{40})\/tokens\/(\d+)\/?$/.exec(url.pathname);
174
- if (m) {
175
- return {
176
- kind: 'token',
177
- source: 'artblocks',
178
- coords: { chain: 'ethereum', contract: m[1].toLowerCase(), tokenId: m[2] },
179
- };
180
- }
181
- m = /^\/collections?\/([a-z0-9][a-z0-9-]*)\/?$/.exec(url.pathname);
182
- if (m) {
183
- return { kind: 'ab-collection', slug: m[1] };
184
- }
185
- if (url.pathname.startsWith('/projects/')) {
186
- return {
187
- kind: 'unsupported',
188
- reason: 'Art Blocks `/projects/{id}` URLs are legacy and no longer resolve on artblocks.io. ' +
189
- 'Paste the current `/collection/{slug}` URL or a specific token URL.',
190
- };
191
- }
192
- return {
193
- kind: 'unsupported',
194
- reason: `Art Blocks URL not recognized: ${url.pathname}.`,
195
- };
196
- }
197
- /**
198
- * fxhash URL forms (Tezos):
199
- * fxhash.xyz/gentk/FX1-{KT1...}-{tokenId} (canonical token URL)
200
- * fxhash.xyz/iteration/{slug} (single iteration; resolves via fxhash API)
201
- * fxhash.xyz/project/{slug} (series; current UI form)
202
- * fxhash.xyz/generative/{slug} (series; legacy URL form, both supported)
203
- * fxhash.xyz/gentk/{numericId} (legacy — needs fxhash API)
204
- *
205
- * Series URLs (`/project/`, `/generative/`) resolve to the project's first
206
- * minted iteration via `generativeToken(slug:)`; Raster then enumerates the
207
- * rest of the series. EVM-only projects won't be found on the Tezos GraphQL
208
- * endpoint and surface as a clean "slug not found" error.
209
- *
210
- * EVM (FX2-prefixed) gentks are not in scope: the FF indexer does not index
211
- * fxhash's EVM contracts in v1.
212
- */
213
- function parseFxhash(url) {
214
- const m = /^\/gentk\/FX1-(KT[A-Za-z0-9]+)-(\d+)\/?$/.exec(url.pathname);
215
- if (m) {
216
- return {
217
- kind: 'token',
218
- source: 'fxhash',
219
- coords: { chain: 'tezos', contract: m[1], tokenId: m[2] },
220
- };
221
- }
222
- if (/^\/gentk\/FX2-/.test(url.pathname)) {
223
- return {
224
- kind: 'unsupported',
225
- reason: 'fxhash FX2 (EVM) tokens are not supported — FF indexer covers Tezos mainnet only ' +
226
- 'for fxhash. Paste a Tezos fxhash token or a different marketplace URL.',
227
- };
228
- }
229
- if (/^\/gentk\/\d+\/?$/.test(url.pathname)) {
230
- return {
231
- kind: 'unsupported',
232
- reason: 'fxhash legacy numeric gentk URLs require an fxhash API lookup — not supported in v1. ' +
233
- 'Paste the FX1-{contract}-{tokenId} form or use `tezos:{contract}:{tokenId}`.',
234
- };
235
- }
236
- const iter = /^\/iteration\/([a-z0-9][a-z0-9-]*)\/?$/.exec(url.pathname);
237
- if (iter) {
238
- return { kind: 'fxhash-iteration', slug: iter[1] };
239
- }
240
- const project = /^\/(?:project|generative)\/([a-z0-9][a-z0-9-]*)\/?$/.exec(url.pathname);
241
- if (project) {
242
- return { kind: 'fxhash-project', slug: project[1] };
243
- }
244
- return {
245
- kind: 'unsupported',
246
- reason: `fxhash URL not recognized: ${url.pathname}.`,
247
- };
248
- }
249
- /**
250
- * OpenSea URL forms:
251
- * opensea.io/assets/{chain}/{contract}/{tokenId}
252
- * opensea.io/item/{chain}/{contract}/{tokenId} — newer naming
253
- * opensea.io/collection/{slug} — collection slug (series)
254
- *
255
- * Only `ethereum` is in scope; other chains (matic, base, solana, etc.)
256
- * fall outside the FF indexer's coverage. Token URLs resolve end-to-end only
257
- * when Raster also indexes the underlying series — Raster's coverage of
258
- * mainstream PFP collections (Azuki, BAYC, etc.) is partial.
259
- */
260
- function parseOpenSea(url) {
261
- const tokenMatch = /^\/(?:assets|item)\/([a-z_]+)\/(0x[a-fA-F0-9]{40})\/(\d+)\/?$/.exec(url.pathname);
262
- if (tokenMatch) {
263
- const chain = tokenMatch[1];
264
- if (chain !== 'ethereum') {
265
- return {
266
- kind: 'unsupported',
267
- reason: `OpenSea ${chain} URLs aren't supported — ff-cli covers Ethereum and Tezos mainnet only. ` +
268
- 'Paste an Ethereum-chain OpenSea URL or a Tezos source (Objkt, fxhash).',
269
- };
270
- }
271
- return {
272
- kind: 'token',
273
- source: 'opensea',
274
- coords: { chain: 'ethereum', contract: tokenMatch[2].toLowerCase(), tokenId: tokenMatch[3] },
275
- };
276
- }
277
- const collectionMatch = /^\/collection\/([a-z0-9][a-z0-9-]*)\/?$/.exec(url.pathname);
278
- if (collectionMatch) {
279
- return { kind: 'os-collection', slug: collectionMatch[1] };
280
- }
281
- if (url.pathname.startsWith('/collection/')) {
282
- return {
283
- kind: 'unsupported',
284
- reason: `OpenSea collection URL not recognized: ${url.pathname}. ` +
285
- 'Expected opensea.io/collection/{slug} with no extra path segments.',
286
- };
287
- }
288
- return {
289
- kind: 'unsupported',
290
- reason: `OpenSea URL not recognized: ${url.pathname}.`,
291
- };
292
- }
293
- /**
294
- * SuperRare URL forms (Ethereum-only platform):
295
- * superrare.com/artwork/eth/{contract}/{tokenId}
296
- * superrare.com/collection/{contract} — per-artist contract page, not supported in v1
297
- * superrare.com/{artist}/{slug} — slug form, not supported in v1
298
- *
299
- * SuperRare's `/artwork/eth/...` URLs encode both the contract and tokenId
300
- * directly, so no API call is needed. Token URLs resolve end-to-end only when
301
- * Raster also indexes the underlying series; for 1/1 artworks the find flow
302
- * falls through to a single-token playlist via the Raster 404 path.
303
- *
304
- * `/collection/{contract}` URLs on SuperRare point to a per-artist contract
305
- * (each SuperRare artist mints from their own ERC-721). There is no direct
306
- * contract→series mapping in Raster, so this is surfaced as a specific
307
- * unsupported message rather than the generic "URL not recognized" fall-through.
308
- */
309
- function parseSuperRare(url) {
310
- const m = /^\/artwork\/eth\/(0x[a-fA-F0-9]{40})\/(\d+)\/?$/.exec(url.pathname);
311
- if (m) {
312
- return {
313
- kind: 'token',
314
- source: 'superrare',
315
- coords: { chain: 'ethereum', contract: m[1].toLowerCase(), tokenId: m[2] },
316
- };
317
- }
318
- if (/^\/collection\/(0x[a-fA-F0-9]{40})\/?$/.test(url.pathname)) {
319
- return {
320
- kind: 'unsupported',
321
- reason: 'SuperRare `/collection/{contract}` URLs (per-artist contract pages) are not yet ' +
322
- 'supported in v1. Paste a specific token URL ' +
323
- '(superrare.com/artwork/eth/{contract}/{tokenId}) or use ' +
324
- '`ethereum:{contract}:{tokenId}`.',
325
- };
326
- }
327
- return {
328
- kind: 'unsupported',
329
- reason: `SuperRare URL not recognized: ${url.pathname}. Expected ` +
330
- '/artwork/eth/{contract}/{tokenId}. For artist-slug URLs, paste the canonical ' +
331
- '/artwork/eth/... form (the SuperRare detail page links it under the artwork title).',
332
- };
333
- }
334
- /**
335
- * Neort URL forms (off-chain code-art platform):
336
- * neort.io/art/{id} — art detail page; id is opaque alphanumeric (~20 chars)
337
- *
338
- * Neort items are not on-chain, so the find flow handles them specially:
339
- * resolve via Neort's public API and build a DP-1 item directly with
340
- * `provenance.offChainURI`, bypassing Raster + FF indexer entirely.
341
- */
342
- function parseNeort(url) {
343
- const m = /^\/art\/([a-zA-Z0-9]+)\/?$/.exec(url.pathname);
344
- if (m) {
345
- return { kind: 'neort-art', id: m[1] };
346
- }
347
- return {
348
- kind: 'unsupported',
349
- reason: `Neort URL not recognized: ${url.pathname}. Expected /art/{id}.`,
350
- };
351
- }
352
- /**
353
- * Verse URL forms:
354
- * verse.works/items/ethereum/{contract}/{tokenId} — direct Ethereum token
355
- * verse.works/series/{slug} — series page
356
- *
357
- * Verse item pages expose the on-chain coordinates directly in the path. Series
358
- * pages do not include a JSON API in the public URL, but they render edition
359
- * links with the same `/items/ethereum/...` shape; the async resolver fetches
360
- * the series page and extracts a representative token for Raster enumeration.
361
- */
362
- function parseVerse(url) {
363
- let m = /^\/items\/ethereum\/(0x[a-fA-F0-9]{40})\/(\d+)\/?$/.exec(url.pathname);
364
- if (m) {
365
- return {
366
- kind: 'token',
367
- source: 'verse',
368
- coords: { chain: 'ethereum', contract: m[1].toLowerCase(), tokenId: m[2] },
369
- };
370
- }
371
- m = /^\/items\/([^/]+)\//.exec(url.pathname);
372
- if (m) {
373
- return {
374
- kind: 'unsupported',
375
- reason: `Verse ${m[1]} item URLs are not supported — ff-cli covers Ethereum and Tezos mainnet only. ` +
376
- 'Paste an Ethereum Verse item URL or raw `ethereum:{contract}:{tokenId}` coordinates.',
377
- };
378
- }
379
- m = /^\/series\/([A-Za-z0-9][A-Za-z0-9_-]*)\/?$/.exec(url.pathname);
380
- if (m) {
381
- return { kind: 'verse-series', slug: m[1] };
382
- }
383
- return {
384
- kind: 'unsupported',
385
- reason: `Verse URL not recognized: ${url.pathname}. Expected ` +
386
- '/items/ethereum/{contract}/{tokenId} or /series/{slug}.',
387
- };
388
- }
389
- /**
390
- * Raster URL forms (raster.art):
391
- * raster.art/artwork/{slug} — artwork (series) detail page
392
- *
393
- * Unlike the other marketplaces, Raster is already the find flow's backend
394
- * resolver — so a Raster URL resolves to an artwork by slug via
395
- * `artworkBySlug` and (crucially) builds DP-1 items straight from Raster's
396
- * own `media.contentUrl`, bypassing the FF indexer the way Neort does. This
397
- * is what lets Raster-minted tokens (which the FF indexer doesn't carry)
398
- * build a verifiable playlist without `--skip-verify`.
399
- */
400
- function parseRaster(url) {
401
- const m = /^\/artwork\/([A-Za-z0-9][A-Za-z0-9_-]*)\/?$/.exec(url.pathname);
402
- if (m) {
403
- return { kind: 'raster-artwork', slug: m[1] };
404
- }
405
- return {
406
- kind: 'unsupported',
407
- reason: `Raster URL not recognized: ${url.pathname}. Expected /artwork/{slug}.`,
408
- };
409
- }
410
- /**
411
- * Feral File URL forms (source of truth: feralfile-client app-routing
412
- * + collection-routing modules):
413
- * /exhibitions/artwork/{tokenId} — single artwork by on-chain tokenId
414
- * /exhibitions/series/{slug} — series detail (multi-token)
415
- * /exhibitions/shows/{slug} — full exhibition (multiple series)
416
- *
417
- * These URLs do not include `chain` or `contract`; FF API walks are required
418
- * to derive them. The parser stays sync — it returns an `ff-url` marker that
419
- * the caller resolves asynchronously via `ff-marketplace#resolveFeralFileToken`.
420
- * Tracked for collapse to a single GET in feral-file/ff-exhibition#3039.
421
- */
422
- function parseFeralFile(url) {
423
- // Public artwork ids may be numeric (legacy: URL id == on-chain tokenID) or
424
- // hex hashes (swapped-id editions). Accept any URL-safe alphanumeric form;
425
- // the FF API rejects unknown ids cleanly downstream.
426
- let m = /^\/exhibitions\/artwork\/([A-Za-z0-9]+)\/?$/.exec(url.pathname);
427
- if (m) {
428
- return { kind: 'ff-url', urlKind: 'artwork', identifier: m[1] };
429
- }
430
- m = /^\/exhibitions\/series\/([^/]+)\/?$/.exec(url.pathname);
431
- if (m) {
432
- return { kind: 'ff-url', urlKind: 'series', identifier: m[1] };
433
- }
434
- m = /^\/exhibitions\/shows\/([^/]+)\/?$/.exec(url.pathname);
435
- if (m) {
436
- return { kind: 'ff-url', urlKind: 'show', identifier: m[1] };
437
- }
438
- return {
439
- kind: 'unsupported',
440
- reason: `Feral File URL not recognized: ${url.pathname}. Supported: ` +
441
- '/exhibitions/artwork/{tokenId}, /exhibitions/series/{slug}, /exhibitions/shows/{slug}.',
442
- };
443
- }