@feralfile/cli 1.1.1 → 1.2.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.
Files changed (36) hide show
  1. package/README.md +26 -0
  2. package/dist/index.js +4 -2
  3. package/dist/package.json +84 -0
  4. package/dist/src/ai-orchestrator/index.js +14 -2
  5. package/dist/src/commands/build.js +3 -3
  6. package/dist/src/commands/chat.js +3 -3
  7. package/dist/src/commands/config.js +2 -2
  8. package/dist/src/commands/device.js +6 -6
  9. package/dist/src/commands/find.js +502 -0
  10. package/dist/src/commands/helpers/device-discovery.js +1 -1
  11. package/dist/src/commands/play.js +52 -11
  12. package/dist/src/commands/setup.js +1 -1
  13. package/dist/src/commands/status.js +2 -2
  14. package/dist/src/intent-parser/index.js +16 -5
  15. package/dist/src/intent-parser/utils.js +7 -2
  16. package/dist/src/main.js +61 -17
  17. package/dist/src/utilities/ab-marketplace.js +95 -0
  18. package/dist/src/utilities/feral-file-artwork.js +89 -0
  19. package/dist/src/utilities/ff-marketplace.js +115 -0
  20. package/dist/src/utilities/ff1-device.js +4 -4
  21. package/dist/src/utilities/functions.js +1 -1
  22. package/dist/src/utilities/fxhash-marketplace.js +146 -0
  23. package/dist/src/utilities/index.js +80 -1
  24. package/dist/src/utilities/marketplace-url.js +373 -0
  25. package/dist/src/utilities/neort-marketplace.js +102 -0
  26. package/dist/src/utilities/nft-indexer.js +22 -14
  27. package/dist/src/utilities/objkt-marketplace.js +77 -0
  28. package/dist/src/utilities/playlist-publisher.js +9 -42
  29. package/dist/src/utilities/raster-client.js +252 -0
  30. package/dist/src/utilities/user-agent.js +20 -0
  31. package/docs/CONFIGURATION.md +3 -1
  32. package/docs/EXAMPLES.md +23 -0
  33. package/docs/FUNCTION_CALLING.md +3 -1
  34. package/docs/README.md +11 -10
  35. package/docs/RELEASING.md +92 -22
  36. package/package.json +9 -1
@@ -0,0 +1,146 @@
1
+ "use strict";
2
+ /**
3
+ * fxhash marketplace resolver.
4
+ *
5
+ * fxhash's UI exposes:
6
+ * - individual iterations under `/iteration/{slug}` (e.g. `garden-monoliths-215`)
7
+ * — resolved via `objkt(slug:)` to (contract, onChainId).
8
+ * - generative projects (series) under `/project/{slug}` and `/generative/{slug}`
9
+ * — resolved via `generativeToken(slug:)` to (contract, first iteration's
10
+ * onChainId), which Raster then reverse-looks-up to enumerate the rest of
11
+ * the series. Mirrors the AB collection flow.
12
+ *
13
+ * Tezos-only: FX1-prefixed gentks live on Tezos. FX2 (EVM) tokens / projects
14
+ * are out of scope. EVM gentk URLs are caught earlier in the URL parser; EVM
15
+ * projects under `/project/{slug}` will simply not resolve via the Tezos
16
+ * GraphQL endpoint and surface as "slug not found".
17
+ */
18
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
19
+ if (k2 === undefined) k2 = k;
20
+ var desc = Object.getOwnPropertyDescriptor(m, k);
21
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
22
+ desc = { enumerable: true, get: function() { return m[k]; } };
23
+ }
24
+ Object.defineProperty(o, k2, desc);
25
+ }) : (function(o, m, k, k2) {
26
+ if (k2 === undefined) k2 = k;
27
+ o[k2] = m[k];
28
+ }));
29
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
30
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
31
+ }) : function(o, v) {
32
+ o["default"] = v;
33
+ });
34
+ var __importStar = (this && this.__importStar) || (function () {
35
+ var ownKeys = function(o) {
36
+ ownKeys = Object.getOwnPropertyNames || function (o) {
37
+ var ar = [];
38
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
39
+ return ar;
40
+ };
41
+ return ownKeys(o);
42
+ };
43
+ return function (mod) {
44
+ if (mod && mod.__esModule) return mod;
45
+ var result = {};
46
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
47
+ __setModuleDefault(result, mod);
48
+ return result;
49
+ };
50
+ })();
51
+ Object.defineProperty(exports, "__esModule", { value: true });
52
+ exports.resolveFxhashIteration = resolveFxhashIteration;
53
+ exports.resolveFxhashProject = resolveFxhashProject;
54
+ const logger = __importStar(require("../logger"));
55
+ const user_agent_1 = require("./user-agent");
56
+ const FXHASH_GRAPHQL = 'https://api.fxhash.xyz/graphql';
57
+ /**
58
+ * Resolve an fxhash iteration URL slug to its on-chain Tezos coordinates.
59
+ *
60
+ * @throws When the slug is unknown, the GraphQL call fails, or the resolved
61
+ * objkt is missing its on-chain fields (un-minted / pre-reveal).
62
+ */
63
+ async function resolveFxhashIteration(slug) {
64
+ logger.debug(`[fxhash] Resolving iteration slug "${slug}"`);
65
+ const query = 'query ($slug: String!) { objkt(slug: $slug) { gentkContractAddress onChainId } }';
66
+ const response = await fetch(FXHASH_GRAPHQL, {
67
+ method: 'POST',
68
+ headers: { 'Content-Type': 'application/json', 'User-Agent': user_agent_1.USER_AGENT },
69
+ body: JSON.stringify({ query, variables: { slug } }),
70
+ });
71
+ if (!response.ok) {
72
+ throw new Error(`fxhash GraphQL ${response.status} ${response.statusText}`);
73
+ }
74
+ const data = (await response.json());
75
+ if (data.errors && data.errors.length > 0) {
76
+ throw new Error(`fxhash GraphQL errors: ${data.errors.map((e) => e.message).join('; ')}`);
77
+ }
78
+ const objkt = data.data?.objkt;
79
+ if (!objkt) {
80
+ throw new Error(`fxhash: no iteration found for slug "${slug}". ` +
81
+ 'Check the URL — slugs come from fxhash.xyz/iteration/{slug}. ' +
82
+ 'Pre-reveal or unminted iterations may not be queryable yet.');
83
+ }
84
+ if (!objkt.gentkContractAddress || objkt.onChainId === null) {
85
+ throw new Error(`fxhash: iteration "${slug}" is missing on-chain coordinates ` +
86
+ '(gentkContractAddress / onChainId). It may be pre-reveal or unassigned.');
87
+ }
88
+ return {
89
+ chain: 'tezos',
90
+ contract: objkt.gentkContractAddress,
91
+ tokenId: String(objkt.onChainId),
92
+ };
93
+ }
94
+ /**
95
+ * Resolve an fxhash generative project slug (e.g. `garden-monoliths`) to its
96
+ * contract + a representative on-chain tokenId — the first minted iteration.
97
+ * Raster then enumerates the rest of the series via the standard reverse
98
+ * lookup, identical to the AB collection flow.
99
+ *
100
+ * Note: each Objkt has its own `gentkContractAddress`. fxhash projects can
101
+ * span multiple gentk contracts (V1/V2/V3 mint phases), so we read the contract
102
+ * off the iteration itself, not the project header.
103
+ *
104
+ * @throws When the slug is unknown (Tezos endpoint won't see EVM-only
105
+ * projects), the project has no minted iterations, or the GraphQL call
106
+ * fails.
107
+ */
108
+ async function resolveFxhashProject(slug) {
109
+ logger.debug(`[fxhash] Resolving project slug "${slug}"`);
110
+ const query = 'query ($slug: String!) {' +
111
+ ' generativeToken(slug: $slug) {' +
112
+ ' objkts(skip: 0, take: 1, sort: { iteration: "ASC" }) {' +
113
+ ' gentkContractAddress' +
114
+ ' onChainId' +
115
+ ' }' +
116
+ ' }' +
117
+ '}';
118
+ const response = await fetch(FXHASH_GRAPHQL, {
119
+ method: 'POST',
120
+ headers: { 'Content-Type': 'application/json', 'User-Agent': user_agent_1.USER_AGENT },
121
+ body: JSON.stringify({ query, variables: { slug } }),
122
+ });
123
+ if (!response.ok) {
124
+ throw new Error(`fxhash GraphQL ${response.status} ${response.statusText}`);
125
+ }
126
+ const data = (await response.json());
127
+ if (data.errors && data.errors.length > 0) {
128
+ throw new Error(`fxhash GraphQL errors: ${data.errors.map((e) => e.message).join('; ')}`);
129
+ }
130
+ const project = data.data?.generativeToken;
131
+ if (!project) {
132
+ throw new Error(`fxhash: no project found for slug "${slug}". ` +
133
+ 'Check the URL — slugs come from fxhash.xyz/project/{slug} or /generative/{slug}. ' +
134
+ 'EVM-only projects on fxhash are not covered (FF indexer is Tezos mainnet only for fxhash).');
135
+ }
136
+ const firstObjkt = project.objkts?.[0];
137
+ if (!firstObjkt || !firstObjkt.gentkContractAddress || firstObjkt.onChainId === null) {
138
+ throw new Error(`fxhash: project "${slug}" has no minted iterations yet, or is pre-reveal. ` +
139
+ 'Paste a specific iteration URL once at least one edition is minted.');
140
+ }
141
+ return {
142
+ chain: 'tezos',
143
+ contract: firstObjkt.gentkContractAddress,
144
+ tokenId: String(firstObjkt.onChainId),
145
+ };
146
+ }
@@ -2,6 +2,39 @@
2
2
  * Utilities - Actual function implementations
3
3
  * Contains the real logic for querying NFTs and building playlists
4
4
  */
5
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
6
+ if (k2 === undefined) k2 = k;
7
+ var desc = Object.getOwnPropertyDescriptor(m, k);
8
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
9
+ desc = { enumerable: true, get: function() { return m[k]; } };
10
+ }
11
+ Object.defineProperty(o, k2, desc);
12
+ }) : (function(o, m, k, k2) {
13
+ if (k2 === undefined) k2 = k;
14
+ o[k2] = m[k];
15
+ }));
16
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
17
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
18
+ }) : function(o, v) {
19
+ o["default"] = v;
20
+ });
21
+ var __importStar = (this && this.__importStar) || (function () {
22
+ var ownKeys = function(o) {
23
+ ownKeys = Object.getOwnPropertyNames || function (o) {
24
+ var ar = [];
25
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
26
+ return ar;
27
+ };
28
+ return ownKeys(o);
29
+ };
30
+ return function (mod) {
31
+ if (mod && mod.__esModule) return mod;
32
+ var result = {};
33
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
34
+ __setModuleDefault(result, mod);
35
+ return result;
36
+ };
37
+ })();
5
38
  const chalk = require('chalk');
6
39
  const nftIndexer = require('./nft-indexer');
7
40
  const feedFetcher = require('./feed-fetcher');
@@ -214,7 +247,7 @@ async function queryTokensByAddress(ownerAddress, quantity, duration = 10, optio
214
247
  * @returns {Promise<Array>} Array of DP1 playlist items
215
248
  */
216
249
  async function queryRequirement(requirement, duration = 10) {
217
- const { type, blockchain, contractAddress, tokenIds, ownerAddress, playlistName, quantity } = requirement;
250
+ const { type, blockchain, contractAddress, tokenIds, ownerAddress, playlistName, quantity, artworkId, } = requirement;
218
251
  // Handle query_address type
219
252
  if (type === 'query_address') {
220
253
  // Check if ownerAddress is a domain name (.eth or .tez)
@@ -262,6 +295,19 @@ async function queryRequirement(requirement, duration = 10) {
262
295
  return [];
263
296
  }
264
297
  }
298
+ if (type === 'feral_file_artwork') {
299
+ console.log(chalk.cyan(`Resolving Feral File artwork ${artworkId}...`));
300
+ const { parseFeralFileArtworkId, resolveFeralFileArtwork } = require('./feral-file-artwork');
301
+ const coords = await resolveFeralFileArtwork(parseFeralFileArtworkId(artworkId));
302
+ const items = await nftIndexer.getNFTTokenInfoBatch([coords], duration);
303
+ if (items.length > 0) {
304
+ console.log(chalk.green(`✓ Got ${items.length} item(s)`));
305
+ }
306
+ else {
307
+ console.log(chalk.yellow(' No items found for Feral File artwork'));
308
+ }
309
+ return items;
310
+ }
265
311
  // Handle build_playlist type (original NFT querying logic)
266
312
  console.log(chalk.cyan(`Querying ${blockchain}${contractAddress ? ' (' + contractAddress.substring(0, 10) + '...)' : ''}...`));
267
313
  let items = [];
@@ -343,11 +389,39 @@ async function buildDP1Playlist(items, title, slug) {
343
389
  /**
344
390
  * Send playlist to FF1 device
345
391
  *
392
+ * This gateway verifies the playlist before any device transport happens.
393
+ * Callers in the AI orchestrator and deterministic build path both route
394
+ * through here, so the delivery policy stays consistent across the CLI.
395
+ * TODO: Propagate structured send failures consistently from every caller;
396
+ * buildPlaylistDirect currently preserves the pre-existing non-fatal behavior.
397
+ *
346
398
  * @param {Object} playlist - DP1 playlist
347
399
  * @param {string} [deviceName] - Device name
348
400
  * @returns {Promise<Object>} Result
349
401
  */
350
402
  async function sendToDevice(playlist, deviceName) {
403
+ const playlistVerifier = await Promise.resolve().then(() => __importStar(require('./playlist-verifier')));
404
+ const verifyPlaylist = playlistVerifier.verifyPlaylist ||
405
+ (playlistVerifier.default && playlistVerifier.default.verifyPlaylist) ||
406
+ playlistVerifier.default;
407
+ if (typeof verifyPlaylist !== 'function') {
408
+ return {
409
+ success: false,
410
+ error: 'Playlist verifier is not available',
411
+ };
412
+ }
413
+ const verificationResult = await verifyPlaylist(playlist);
414
+ if (!verificationResult.valid) {
415
+ return {
416
+ success: false,
417
+ error: verificationResult.error || 'Failed to verify playlist before sending',
418
+ details: verificationResult.details
419
+ ? verificationResult.details
420
+ .map((detail) => `• ${detail.path}: ${detail.message}`)
421
+ .join('\n')
422
+ : undefined,
423
+ };
424
+ }
351
425
  const { sendPlaylistToDevice } = require('./functions');
352
426
  return await sendPlaylistToDevice({ playlist, deviceName });
353
427
  }
@@ -367,6 +441,11 @@ function shuffleArray(array) {
367
441
  /**
368
442
  * Build playlist directly from requirements (deterministic, no AI)
369
443
  *
444
+ * TODO: Treat requested device send and feed publish failures consistently.
445
+ * This function currently keeps the pre-existing main-branch behavior where
446
+ * send/publish failures are non-fatal to the playlist build; fix that in a
447
+ * dedicated follow-up when the command contract changes.
448
+ *
370
449
  * @param {Object} params - Playlist parameters
371
450
  * @param {Array<Object>} params.requirements - Array of requirements
372
451
  * @param {Object} params.playlistSettings - Playlist settings
@@ -0,0 +1,373 @@
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)
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.parseFeralFile = parseFeralFile;
29
+ const ETH_ADDR = /^0x[a-fA-F0-9]{40}$/;
30
+ const TEZOS_ADDR = /^(tz1|tz2|tz3)[1-9A-HJ-NP-Za-km-z]{33}$/;
31
+ const RAW_COORDS = /^(ethereum|tezos):([^:]+):([^:]+)$/i;
32
+ /**
33
+ * Parse any user-supplied input for the `ff-cli find` command.
34
+ *
35
+ * Returns:
36
+ * - `{ kind: 'token', ... }` — successfully extracted on-chain coordinates
37
+ * - `{ kind: 'address', ... }` — wallet/artist address (for the artist path)
38
+ * - `{ kind: 'unsupported', reason }` — recognized but cannot extract a token
39
+ * - `null` — input does not match any recognized form; caller may show help
40
+ */
41
+ function parseFindInput(input) {
42
+ const trimmed = input.trim();
43
+ if (!trimmed) {
44
+ return null;
45
+ }
46
+ if (ETH_ADDR.test(trimmed)) {
47
+ return { kind: 'address', chain: 'ethereum', address: trimmed.toLowerCase() };
48
+ }
49
+ if (TEZOS_ADDR.test(trimmed)) {
50
+ return { kind: 'address', chain: 'tezos', address: trimmed };
51
+ }
52
+ const rawMatch = RAW_COORDS.exec(trimmed);
53
+ if (rawMatch) {
54
+ return {
55
+ kind: 'token',
56
+ source: 'raw',
57
+ coords: {
58
+ chain: rawMatch[1].toLowerCase(),
59
+ contract: normalizeContract(rawMatch[2]),
60
+ tokenId: rawMatch[3],
61
+ },
62
+ };
63
+ }
64
+ let parsedUrl;
65
+ try {
66
+ parsedUrl = new URL(trimmed);
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ return parseMarketplaceUrl(parsedUrl);
72
+ }
73
+ /**
74
+ * Dispatch a URL to the per-host parser. Returns `null` when the host is
75
+ * not one of the supported sources.
76
+ */
77
+ function parseMarketplaceUrl(url) {
78
+ const host = url.hostname.toLowerCase().replace(/^www\./, '');
79
+ if (host === 'objkt.com' || host.endsWith('.objkt.com')) {
80
+ return parseObjkt(url);
81
+ }
82
+ if (host === 'artblocks.io' || host.endsWith('.artblocks.io')) {
83
+ return parseArtBlocks(url);
84
+ }
85
+ if (host === 'fxhash.xyz' || host.endsWith('.fxhash.xyz')) {
86
+ return parseFxhash(url);
87
+ }
88
+ if (host === 'feralfile.com' || host.endsWith('.feralfile.com')) {
89
+ return parseFeralFile(url);
90
+ }
91
+ if (host === 'opensea.io' || host.endsWith('.opensea.io')) {
92
+ return parseOpenSea(url);
93
+ }
94
+ if (host === 'superrare.com' || host.endsWith('.superrare.com')) {
95
+ return parseSuperRare(url);
96
+ }
97
+ if (host === 'neort.io' || host.endsWith('.neort.io')) {
98
+ return parseNeort(url);
99
+ }
100
+ return null;
101
+ }
102
+ /**
103
+ * Lowercase Ethereum contracts; leave Tezos KT1 contracts case-sensitive
104
+ * (base58 prefix is case-significant on Tezos).
105
+ */
106
+ function normalizeContract(contract) {
107
+ return contract.startsWith('0x') ? contract.toLowerCase() : contract;
108
+ }
109
+ /**
110
+ * Objkt URL forms:
111
+ * objkt.com/tokens/{KT1...}/{tokenId} — raw FA contract
112
+ * objkt.com/tokens/{alias}/{tokenId} — collection alias (e.g. "hicetnunc")
113
+ * objkt.com/asset/{KT1...}/{tokenId} — legacy redirect
114
+ *
115
+ * Aliases are resolved to KT1 contracts via
116
+ * {@link ./objkt-marketplace#resolveObjktAlias} (separate file because the
117
+ * lookup is async).
118
+ */
119
+ function parseObjkt(url) {
120
+ const direct = /^\/(?:tokens|asset)\/(KT[A-Za-z0-9]+)\/(\d+)\/?$/.exec(url.pathname);
121
+ if (direct) {
122
+ return {
123
+ kind: 'token',
124
+ source: 'objkt',
125
+ coords: { chain: 'tezos', contract: direct[1], tokenId: direct[2] },
126
+ };
127
+ }
128
+ const alias = /^\/(?:tokens|asset)\/([a-zA-Z][a-zA-Z0-9_-]*)\/(\d+)\/?$/.exec(url.pathname);
129
+ if (alias) {
130
+ return { kind: 'objkt-alias', alias: alias[1], tokenId: alias[2] };
131
+ }
132
+ if (url.pathname.startsWith('/collections/') || url.pathname.startsWith('/collection/')) {
133
+ return {
134
+ kind: 'unsupported',
135
+ reason: 'Objkt collection URLs are not yet supported in v1. Paste a specific token URL ' +
136
+ '(objkt.com/tokens/{contract-or-alias}/{tokenId}) or use `tezos:{contract}:{tokenId}`.',
137
+ };
138
+ }
139
+ return {
140
+ kind: 'unsupported',
141
+ reason: `Objkt URL not recognized: ${url.pathname}. Expected /tokens/{contract-or-alias}/{tokenId}.`,
142
+ };
143
+ }
144
+ /**
145
+ * Art Blocks URL forms:
146
+ * artblocks.io/token/{0xcontract}-{tokenId} — direct token
147
+ * artblocks.io/marketplace/collections/{0xcontract}/tokens/{id} — marketplace UI token
148
+ * artblocks.io/collection/{slug} — collection (series)
149
+ * artblocks.io/collections/{slug} — plural; 307→singular
150
+ *
151
+ * Collection slugs are resolved to (contract, first_tokenId) via
152
+ * {@link ./ab-marketplace#resolveArtBlocksCollection} (one Hasura GraphQL
153
+ * call). `/projects/{id}` legacy URLs return 404 on artblocks.io and are
154
+ * surfaced as a clear unsupported message.
155
+ */
156
+ function parseArtBlocks(url) {
157
+ let m = /^\/token\/(0x[a-fA-F0-9]{40})-(\d+)\/?$/.exec(url.pathname);
158
+ if (m) {
159
+ return {
160
+ kind: 'token',
161
+ source: 'artblocks',
162
+ coords: { chain: 'ethereum', contract: m[1].toLowerCase(), tokenId: m[2] },
163
+ };
164
+ }
165
+ m = /^\/marketplace\/collections\/(0x[a-fA-F0-9]{40})\/tokens\/(\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 = /^\/collections?\/([a-z0-9][a-z0-9-]*)\/?$/.exec(url.pathname);
174
+ if (m) {
175
+ return { kind: 'ab-collection', slug: m[1] };
176
+ }
177
+ if (url.pathname.startsWith('/projects/')) {
178
+ return {
179
+ kind: 'unsupported',
180
+ reason: 'Art Blocks `/projects/{id}` URLs are legacy and no longer resolve on artblocks.io. ' +
181
+ 'Paste the current `/collection/{slug}` URL or a specific token URL.',
182
+ };
183
+ }
184
+ return {
185
+ kind: 'unsupported',
186
+ reason: `Art Blocks URL not recognized: ${url.pathname}.`,
187
+ };
188
+ }
189
+ /**
190
+ * fxhash URL forms (Tezos):
191
+ * fxhash.xyz/gentk/FX1-{KT1...}-{tokenId} (canonical token URL)
192
+ * fxhash.xyz/iteration/{slug} (single iteration; resolves via fxhash API)
193
+ * fxhash.xyz/project/{slug} (series; current UI form)
194
+ * fxhash.xyz/generative/{slug} (series; legacy URL form, both supported)
195
+ * fxhash.xyz/gentk/{numericId} (legacy — needs fxhash API)
196
+ *
197
+ * Series URLs (`/project/`, `/generative/`) resolve to the project's first
198
+ * minted iteration via `generativeToken(slug:)`; Raster then enumerates the
199
+ * rest of the series. EVM-only projects won't be found on the Tezos GraphQL
200
+ * endpoint and surface as a clean "slug not found" error.
201
+ *
202
+ * EVM (FX2-prefixed) gentks are not in scope: the FF indexer does not index
203
+ * fxhash's EVM contracts in v1.
204
+ */
205
+ function parseFxhash(url) {
206
+ const m = /^\/gentk\/FX1-(KT[A-Za-z0-9]+)-(\d+)\/?$/.exec(url.pathname);
207
+ if (m) {
208
+ return {
209
+ kind: 'token',
210
+ source: 'fxhash',
211
+ coords: { chain: 'tezos', contract: m[1], tokenId: m[2] },
212
+ };
213
+ }
214
+ if (/^\/gentk\/FX2-/.test(url.pathname)) {
215
+ return {
216
+ kind: 'unsupported',
217
+ reason: 'fxhash FX2 (EVM) tokens are not supported — FF indexer covers Tezos mainnet only ' +
218
+ 'for fxhash. Paste a Tezos fxhash token or a different marketplace URL.',
219
+ };
220
+ }
221
+ if (/^\/gentk\/\d+\/?$/.test(url.pathname)) {
222
+ return {
223
+ kind: 'unsupported',
224
+ reason: 'fxhash legacy numeric gentk URLs require an fxhash API lookup — not supported in v1. ' +
225
+ 'Paste the FX1-{contract}-{tokenId} form or use `tezos:{contract}:{tokenId}`.',
226
+ };
227
+ }
228
+ const iter = /^\/iteration\/([a-z0-9][a-z0-9-]*)\/?$/.exec(url.pathname);
229
+ if (iter) {
230
+ return { kind: 'fxhash-iteration', slug: iter[1] };
231
+ }
232
+ const project = /^\/(?:project|generative)\/([a-z0-9][a-z0-9-]*)\/?$/.exec(url.pathname);
233
+ if (project) {
234
+ return { kind: 'fxhash-project', slug: project[1] };
235
+ }
236
+ return {
237
+ kind: 'unsupported',
238
+ reason: `fxhash URL not recognized: ${url.pathname}.`,
239
+ };
240
+ }
241
+ /**
242
+ * OpenSea URL forms:
243
+ * opensea.io/assets/{chain}/{contract}/{tokenId}
244
+ * opensea.io/item/{chain}/{contract}/{tokenId} — newer naming
245
+ * opensea.io/collection/{slug} — series slug (not yet)
246
+ *
247
+ * Only `ethereum` is in scope; other chains (matic, base, solana, etc.)
248
+ * fall outside the FF indexer's coverage. Token URLs resolve end-to-end only
249
+ * when Raster also indexes the underlying series — Raster's coverage of
250
+ * mainstream PFP collections (Azuki, BAYC, etc.) is partial.
251
+ */
252
+ function parseOpenSea(url) {
253
+ const tokenMatch = /^\/(?:assets|item)\/([a-z_]+)\/(0x[a-fA-F0-9]{40})\/(\d+)\/?$/.exec(url.pathname);
254
+ if (tokenMatch) {
255
+ const chain = tokenMatch[1];
256
+ if (chain !== 'ethereum') {
257
+ return {
258
+ kind: 'unsupported',
259
+ reason: `OpenSea ${chain} URLs aren't supported — ff-cli covers Ethereum and Tezos mainnet only. ` +
260
+ 'Paste an Ethereum-chain OpenSea URL or a Tezos source (Objkt, fxhash).',
261
+ };
262
+ }
263
+ return {
264
+ kind: 'token',
265
+ source: 'opensea',
266
+ coords: { chain: 'ethereum', contract: tokenMatch[2].toLowerCase(), tokenId: tokenMatch[3] },
267
+ };
268
+ }
269
+ if (url.pathname.startsWith('/collection/')) {
270
+ return {
271
+ kind: 'unsupported',
272
+ reason: 'OpenSea `/collection/{slug}` URLs are not yet supported in v1. Paste a specific token ' +
273
+ 'URL (opensea.io/assets/ethereum/{contract}/{tokenId}) or use `ethereum:{contract}:{tokenId}`.',
274
+ };
275
+ }
276
+ return {
277
+ kind: 'unsupported',
278
+ reason: `OpenSea URL not recognized: ${url.pathname}.`,
279
+ };
280
+ }
281
+ /**
282
+ * SuperRare URL forms (Ethereum-only platform):
283
+ * superrare.com/artwork/eth/{contract}/{tokenId}
284
+ * superrare.com/collection/{contract} — per-artist contract page, not supported in v1
285
+ * superrare.com/{artist}/{slug} — slug form, not supported in v1
286
+ *
287
+ * SuperRare's `/artwork/eth/...` URLs encode both the contract and tokenId
288
+ * directly, so no API call is needed. Token URLs resolve end-to-end only when
289
+ * Raster also indexes the underlying series; for 1/1 artworks the find flow
290
+ * falls through to a single-token playlist via the Raster 404 path.
291
+ *
292
+ * `/collection/{contract}` URLs on SuperRare point to a per-artist contract
293
+ * (each SuperRare artist mints from their own ERC-721). There is no direct
294
+ * contract→series mapping in Raster, so this is surfaced as a specific
295
+ * unsupported message rather than the generic "URL not recognized" fall-through.
296
+ */
297
+ function parseSuperRare(url) {
298
+ const m = /^\/artwork\/eth\/(0x[a-fA-F0-9]{40})\/(\d+)\/?$/.exec(url.pathname);
299
+ if (m) {
300
+ return {
301
+ kind: 'token',
302
+ source: 'superrare',
303
+ coords: { chain: 'ethereum', contract: m[1].toLowerCase(), tokenId: m[2] },
304
+ };
305
+ }
306
+ if (/^\/collection\/(0x[a-fA-F0-9]{40})\/?$/.test(url.pathname)) {
307
+ return {
308
+ kind: 'unsupported',
309
+ reason: 'SuperRare `/collection/{contract}` URLs (per-artist contract pages) are not yet ' +
310
+ 'supported in v1. Paste a specific token URL ' +
311
+ '(superrare.com/artwork/eth/{contract}/{tokenId}) or use ' +
312
+ '`ethereum:{contract}:{tokenId}`.',
313
+ };
314
+ }
315
+ return {
316
+ kind: 'unsupported',
317
+ reason: `SuperRare URL not recognized: ${url.pathname}. Expected ` +
318
+ '/artwork/eth/{contract}/{tokenId}. For artist-slug URLs, paste the canonical ' +
319
+ '/artwork/eth/... form (the SuperRare detail page links it under the artwork title).',
320
+ };
321
+ }
322
+ /**
323
+ * Neort URL forms (off-chain code-art platform):
324
+ * neort.io/art/{id} — art detail page; id is opaque alphanumeric (~20 chars)
325
+ *
326
+ * Neort items are not on-chain, so the find flow handles them specially:
327
+ * resolve via Neort's public API and build a DP-1 item directly with
328
+ * `provenance.offChainURI`, bypassing Raster + FF indexer entirely.
329
+ */
330
+ function parseNeort(url) {
331
+ const m = /^\/art\/([a-zA-Z0-9]+)\/?$/.exec(url.pathname);
332
+ if (m) {
333
+ return { kind: 'neort-art', id: m[1] };
334
+ }
335
+ return {
336
+ kind: 'unsupported',
337
+ reason: `Neort URL not recognized: ${url.pathname}. Expected /art/{id}.`,
338
+ };
339
+ }
340
+ /**
341
+ * Feral File URL forms (source of truth: feralfile-client app-routing
342
+ * + collection-routing modules):
343
+ * /exhibitions/artwork/{tokenId} — single artwork by on-chain tokenId
344
+ * /exhibitions/series/{slug} — series detail (multi-token)
345
+ * /exhibitions/shows/{slug} — full exhibition (multiple series)
346
+ *
347
+ * These URLs do not include `chain` or `contract`; FF API walks are required
348
+ * to derive them. The parser stays sync — it returns an `ff-url` marker that
349
+ * the caller resolves asynchronously via `ff-marketplace#resolveFeralFileToken`.
350
+ * Tracked for collapse to a single GET in feral-file/ff-exhibition#3039.
351
+ */
352
+ function parseFeralFile(url) {
353
+ // Public artwork ids may be numeric (legacy: URL id == on-chain tokenID) or
354
+ // hex hashes (swapped-id editions). Accept any URL-safe alphanumeric form;
355
+ // the FF API rejects unknown ids cleanly downstream.
356
+ let m = /^\/exhibitions\/artwork\/([A-Za-z0-9]+)\/?$/.exec(url.pathname);
357
+ if (m) {
358
+ return { kind: 'ff-url', urlKind: 'artwork', identifier: m[1] };
359
+ }
360
+ m = /^\/exhibitions\/series\/([^/]+)\/?$/.exec(url.pathname);
361
+ if (m) {
362
+ return { kind: 'ff-url', urlKind: 'series', identifier: m[1] };
363
+ }
364
+ m = /^\/exhibitions\/shows\/([^/]+)\/?$/.exec(url.pathname);
365
+ if (m) {
366
+ return { kind: 'ff-url', urlKind: 'show', identifier: m[1] };
367
+ }
368
+ return {
369
+ kind: 'unsupported',
370
+ reason: `Feral File URL not recognized: ${url.pathname}. Supported: ` +
371
+ '/exhibitions/artwork/{tokenId}, /exhibitions/series/{slug}, /exhibitions/shows/{slug}.',
372
+ };
373
+ }