ani-client 1.6.0 → 1.6.1

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.
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  > A simple, typed client to fetch anime, manga, character, staff and user data from [AniList](https://anilist.co).
9
9
 
10
- ✨ **Showcase**: [Check here](https://gonzyuidev.xyz/projects/aniclient/showcase) to see which projects use this package!
10
+ ✨ **Showcase**: [Check here](https://ani-client.js.org/showcase) to see which projects use this package!
11
11
 
12
12
  - **Zero dependencies** — uses the native `fetch` API
13
13
  - **Universal** — Node.js ≥ 20, Bun, Deno and modern browsers
@@ -18,7 +18,7 @@
18
18
 
19
19
  The full API reference, usage guide, and configuration examples are available on our official documentation website!
20
20
 
21
- **[👉 View the full documentation here](https://gonzyuidev.xyz/projects/aniclient/)**
21
+ **[👉 View the full documentation here](https://ani-client.js.org)**
22
22
 
23
23
  ## Install
24
24
 
package/dist/index.d.mts CHANGED
@@ -90,6 +90,8 @@ interface AniListHooks {
90
90
  onRetry?: (attempt: number, reason: string, delayMs: number) => void;
91
91
  /** Called when a request completes. */
92
92
  onResponse?: (query: string, durationMs: number, fromCache: boolean, rateLimitInfo?: RateLimitInfo) => void;
93
+ /** Called when a request fails with an error. */
94
+ onError?: (error: Error, query: string, variables: Record<string, unknown>) => void;
93
95
  }
94
96
  /** Rate limit information parsed from AniList API response headers. */
95
97
  interface RateLimitInfo {
@@ -1077,7 +1079,7 @@ declare class AniListClient {
1077
1079
  /** Clear the entire response cache. */
1078
1080
  clearCache(): Promise<void>;
1079
1081
  /** Number of entries currently in the cache. */
1080
- get cacheSize(): number | Promise<number>;
1082
+ cacheSize(): Promise<number>;
1081
1083
  /** Remove cache entries whose key matches the given pattern. */
1082
1084
  invalidateCache(pattern: string | RegExp): Promise<number>;
1083
1085
  /** Clean up resources held by the client. */
@@ -1219,6 +1221,8 @@ declare class RateLimiter {
1219
1221
  private readonly timestamps;
1220
1222
  private head;
1221
1223
  private count;
1224
+ /** @internal — active sleep timers for cleanup */
1225
+ private readonly activeTimers;
1222
1226
  constructor(options?: RateLimitOptions);
1223
1227
  /**
1224
1228
  * Wait until it's safe to make a request (respects rate limit window).
@@ -1237,11 +1241,19 @@ declare class RateLimiter {
1237
1241
  /** @internal */
1238
1242
  private fetchWithTimeout;
1239
1243
  private sleep;
1244
+ /** Cancel all pending sleep timers and reset internal state. */
1245
+ dispose(): void;
1240
1246
  }
1241
1247
 
1242
1248
  /**
1243
1249
  * Parses AniList specific markdown into standard HTML.
1244
- * Includes formatting for spoilers, images, videos (youtube/webm), and standard markdown elements.
1250
+ * Includes formatting for spoilers, images, videos (youtube/webm), headings,
1251
+ * lists, code blocks, and standard markdown elements.
1252
+ *
1253
+ * @security This function escapes HTML entities to prevent XSS attacks.
1254
+ * However, the output is still raw HTML — consumers should always use a
1255
+ * Content Security Policy and consider additional sanitization when rendering
1256
+ * user-generated content in a browser.
1245
1257
  *
1246
1258
  * @param text The AniList markdown text to parse
1247
1259
  * @returns The parsed HTML string
package/dist/index.d.ts CHANGED
@@ -90,6 +90,8 @@ interface AniListHooks {
90
90
  onRetry?: (attempt: number, reason: string, delayMs: number) => void;
91
91
  /** Called when a request completes. */
92
92
  onResponse?: (query: string, durationMs: number, fromCache: boolean, rateLimitInfo?: RateLimitInfo) => void;
93
+ /** Called when a request fails with an error. */
94
+ onError?: (error: Error, query: string, variables: Record<string, unknown>) => void;
93
95
  }
94
96
  /** Rate limit information parsed from AniList API response headers. */
95
97
  interface RateLimitInfo {
@@ -1077,7 +1079,7 @@ declare class AniListClient {
1077
1079
  /** Clear the entire response cache. */
1078
1080
  clearCache(): Promise<void>;
1079
1081
  /** Number of entries currently in the cache. */
1080
- get cacheSize(): number | Promise<number>;
1082
+ cacheSize(): Promise<number>;
1081
1083
  /** Remove cache entries whose key matches the given pattern. */
1082
1084
  invalidateCache(pattern: string | RegExp): Promise<number>;
1083
1085
  /** Clean up resources held by the client. */
@@ -1219,6 +1221,8 @@ declare class RateLimiter {
1219
1221
  private readonly timestamps;
1220
1222
  private head;
1221
1223
  private count;
1224
+ /** @internal — active sleep timers for cleanup */
1225
+ private readonly activeTimers;
1222
1226
  constructor(options?: RateLimitOptions);
1223
1227
  /**
1224
1228
  * Wait until it's safe to make a request (respects rate limit window).
@@ -1237,11 +1241,19 @@ declare class RateLimiter {
1237
1241
  /** @internal */
1238
1242
  private fetchWithTimeout;
1239
1243
  private sleep;
1244
+ /** Cancel all pending sleep timers and reset internal state. */
1245
+ dispose(): void;
1240
1246
  }
1241
1247
 
1242
1248
  /**
1243
1249
  * Parses AniList specific markdown into standard HTML.
1244
- * Includes formatting for spoilers, images, videos (youtube/webm), and standard markdown elements.
1250
+ * Includes formatting for spoilers, images, videos (youtube/webm), headings,
1251
+ * lists, code blocks, and standard markdown elements.
1252
+ *
1253
+ * @security This function escapes HTML entities to prevent XSS attacks.
1254
+ * However, the output is still raw HTML — consumers should always use a
1255
+ * Content Security Policy and consider additional sanitization when rendering
1256
+ * user-generated content in a browser.
1245
1257
  *
1246
1258
  * @param text The AniList markdown text to parse
1247
1259
  * @returns The parsed HTML string
package/dist/index.js CHANGED
@@ -4,6 +4,11 @@
4
4
  function parseAniListMarkdown(text) {
5
5
  if (!text) return "";
6
6
  let html = text;
7
+ html = html.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
8
+ html = html.replace(/```([\s\S]*?)```/g, (_match, code) => {
9
+ return `<pre><code>${code.trim()}</code></pre>`;
10
+ });
11
+ html = html.replace(/`([^`]+)`/g, "<code>$1</code>");
7
12
  html = html.replace(/~!(.*?)!~/gs, '<span class="anilist-spoiler">$1</span>');
8
13
  html = html.replace(/~~~(.*?)~~~/gs, '<div class="anilist-center">$1</div>');
9
14
  html = html.replace(/img(\d+)\((.*?)\)/gi, '<img src="$2" width="$1" alt="" class="anilist-image" />');
@@ -13,6 +18,12 @@ function parseAniListMarkdown(text) {
13
18
  '<iframe src="https://www.youtube.com/embed/$1" frameborder="0" allowfullscreen class="anilist-youtube"></iframe>'
14
19
  );
15
20
  html = html.replace(/webm\((.*?)\)/gi, '<video src="$1" controls class="anilist-webm"></video>');
21
+ html = html.replace(/^######\s+(.+)$/gm, "<h6>$1</h6>");
22
+ html = html.replace(/^#####\s+(.+)$/gm, "<h5>$1</h5>");
23
+ html = html.replace(/^####\s+(.+)$/gm, "<h4>$1</h4>");
24
+ html = html.replace(/^###\s+(.+)$/gm, "<h3>$1</h3>");
25
+ html = html.replace(/^##\s+(.+)$/gm, "<h2>$1</h2>");
26
+ html = html.replace(/^#\s+(.+)$/gm, "<h1>$1</h1>");
16
27
  html = html.replace(/__(.*?)__/g, "<strong>$1</strong>");
17
28
  html = html.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>");
18
29
  html = html.replace(/_(.*?)_/g, "<em>$1</em>");
@@ -20,14 +31,46 @@ function parseAniListMarkdown(text) {
20
31
  html = html.replace(/~~(.*?)~~/g, "<del>$1</del>");
21
32
  html = html.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
22
33
  html = html.replace(/\r\n/g, "\n");
34
+ const lines = html.split("\n");
35
+ const processed = [];
36
+ let listType = null;
37
+ for (const line of lines) {
38
+ const ulMatch = line.match(/^[\s]*[-*]\s+(.*)/);
39
+ const olMatch = line.match(/^[\s]*\d+\.\s+(.*)/);
40
+ if (ulMatch) {
41
+ if (listType !== "ul") {
42
+ if (listType) processed.push(`</${listType}>`);
43
+ processed.push("<ul>");
44
+ listType = "ul";
45
+ }
46
+ processed.push(`<li>${ulMatch[1]}</li>`);
47
+ } else if (olMatch) {
48
+ if (listType !== "ol") {
49
+ if (listType) processed.push(`</${listType}>`);
50
+ processed.push("<ol>");
51
+ listType = "ol";
52
+ }
53
+ processed.push(`<li>${olMatch[1]}</li>`);
54
+ } else {
55
+ if (listType) {
56
+ processed.push(`</${listType}>`);
57
+ listType = null;
58
+ }
59
+ processed.push(line);
60
+ }
61
+ }
62
+ if (listType) processed.push(`</${listType}>`);
63
+ html = processed.join("\n");
23
64
  const paragraphs = html.split(/\n{2,}/);
24
65
  html = paragraphs.map((p) => {
25
- const withBr = p.replace(/\n/g, "<br />");
26
- if (withBr.match(/^(<div|<iframe|<video|<img)/)) {
66
+ const trimmed = p.trim();
67
+ if (!trimmed) return "";
68
+ const withBr = trimmed.replace(/\n/g, "<br />");
69
+ if (withBr.match(/^(<div|<iframe|<video|<img|<h[1-6]|<ul|<ol|<pre)/)) {
27
70
  return withBr;
28
71
  }
29
72
  return `<p>${withBr}</p>`;
30
- }).join("\n");
73
+ }).filter(Boolean).join("\n");
31
74
  return html;
32
75
  }
33
76
 
@@ -802,7 +845,7 @@ function buildBatchQuery(ids, typeName, fields, prefix) {
802
845
  ${aliases}
803
846
  }`;
804
847
  }
805
- var buildBatchMediaQuery = (ids) => buildBatchQuery(ids, "Media", MEDIA_FIELDS_BASE, "m");
848
+ var buildBatchMediaQuery = (ids) => buildBatchQuery(ids, "Media", MEDIA_FIELDS, "m");
806
849
  var buildBatchCharacterQuery = (ids) => buildBatchQuery(ids, "Character", CHARACTER_FIELDS, "c");
807
850
  var buildBatchStaffQuery = (ids) => buildBatchQuery(ids, "Staff", STAFF_FIELDS, "s");
808
851
 
@@ -870,6 +913,8 @@ var RateLimiter = class {
870
913
  constructor(options = {}) {
871
914
  this.head = 0;
872
915
  this.count = 0;
916
+ /** @internal — active sleep timers for cleanup */
917
+ this.activeTimers = /* @__PURE__ */ new Set();
873
918
  this.maxRequests = options.maxRequests ?? 85;
874
919
  this.windowMs = options.windowMs ?? 6e4;
875
920
  this.maxRetries = options.maxRetries ?? 3;
@@ -887,8 +932,7 @@ var RateLimiter = class {
887
932
  if (!this.enabled) return;
888
933
  if (this.count >= this.maxRequests) {
889
934
  const oldest = this.timestamps[this.head];
890
- const now2 = Date.now();
891
- const elapsed = now2 - oldest;
935
+ const elapsed = Date.now() - oldest;
892
936
  if (elapsed < this.windowMs) {
893
937
  const waitMs = this.windowMs - elapsed + 50;
894
938
  await this.sleep(waitMs);
@@ -952,14 +996,32 @@ var RateLimiter = class {
952
996
  if (this.timeoutMs <= 0) return fetch(url, init);
953
997
  const controller = new AbortController();
954
998
  const timer = setTimeout(() => controller.abort(), this.timeoutMs);
999
+ const signals = [controller.signal, init.signal].filter(Boolean);
1000
+ const combinedSignal = signals.length > 1 ? AbortSignal.any(signals) : signals[0];
955
1001
  try {
956
- return await fetch(url, { ...init, signal: controller.signal });
1002
+ return await fetch(url, { ...init, signal: combinedSignal });
957
1003
  } finally {
958
1004
  clearTimeout(timer);
959
1005
  }
960
1006
  }
961
1007
  sleep(ms) {
962
- return new Promise((resolve) => setTimeout(resolve, ms));
1008
+ return new Promise((resolve) => {
1009
+ const timer = setTimeout(() => {
1010
+ this.activeTimers.delete(timer);
1011
+ resolve();
1012
+ }, ms);
1013
+ this.activeTimers.add(timer);
1014
+ });
1015
+ }
1016
+ /** Cancel all pending sleep timers and reset internal state. */
1017
+ dispose() {
1018
+ for (const timer of this.activeTimers) {
1019
+ clearTimeout(timer);
1020
+ }
1021
+ this.activeTimers.clear();
1022
+ this.head = 0;
1023
+ this.count = 0;
1024
+ this.timestamps.fill(0);
963
1025
  }
964
1026
  };
965
1027
  var RETRYABLE_NETWORK_CODES = /* @__PURE__ */ new Set([
@@ -1333,14 +1395,12 @@ async function getWeeklySchedule(client, date = /* @__PURE__ */ new Date()) {
1333
1395
  Saturday: [],
1334
1396
  Sunday: []
1335
1397
  };
1336
- const startOfWeek = new Date(date);
1337
- const day = startOfWeek.getDay();
1338
- const diff = startOfWeek.getDate() - day + (day === 0 ? -6 : 1);
1339
- startOfWeek.setDate(diff);
1340
- startOfWeek.setHours(0, 0, 0, 0);
1398
+ const utcDay = date.getUTCDay();
1399
+ const diff = utcDay === 0 ? -6 : 1 - utcDay;
1400
+ const startOfWeek = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + diff, 0, 0, 0));
1341
1401
  const endOfWeek = new Date(startOfWeek);
1342
- endOfWeek.setDate(startOfWeek.getDate() + 6);
1343
- endOfWeek.setHours(23, 59, 59, 999);
1402
+ endOfWeek.setUTCDate(startOfWeek.getUTCDate() + 6);
1403
+ endOfWeek.setUTCHours(23, 59, 59, 999);
1344
1404
  const startTimestamp = Math.floor(startOfWeek.getTime() / 1e3);
1345
1405
  const endTimestamp = Math.floor(endOfWeek.getTime() / 1e3);
1346
1406
  const iterator = client.paginate(
@@ -1354,7 +1414,7 @@ async function getWeeklySchedule(client, date = /* @__PURE__ */ new Date()) {
1354
1414
  const names = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
1355
1415
  for await (const episode of iterator) {
1356
1416
  const epDate = new Date(episode.airingAt * 1e3);
1357
- const dayName = names[epDate.getDay()];
1417
+ const dayName = names[epDate.getUTCDay()];
1358
1418
  schedule[dayName].push(episode);
1359
1419
  }
1360
1420
  return schedule;
@@ -1477,13 +1537,15 @@ function mapFavorites(fav) {
1477
1537
 
1478
1538
  // src/client/index.ts
1479
1539
  var DEFAULT_API_URL = "https://graphql.anilist.co";
1540
+ var LIB_VERSION = "1.6.1" ;
1480
1541
  var AniListClient = class {
1481
1542
  constructor(options = {}) {
1482
1543
  this.inFlight = /* @__PURE__ */ new Map();
1483
1544
  this.apiUrl = options.apiUrl ?? DEFAULT_API_URL;
1484
1545
  this.headers = {
1485
1546
  "Content-Type": "application/json",
1486
- Accept: "application/json"
1547
+ Accept: "application/json",
1548
+ "User-Agent": `ani-client/${LIB_VERSION}`
1487
1549
  };
1488
1550
  if (options.token) {
1489
1551
  this.headers.Authorization = `Bearer ${options.token}`;
@@ -1507,7 +1569,6 @@ var AniListClient = class {
1507
1569
  get lastRequestMeta() {
1508
1570
  return this._lastRequestMeta;
1509
1571
  }
1510
- // ── Core infrastructure (internal) ──
1511
1572
  /** @internal */
1512
1573
  async request(query, variables = {}) {
1513
1574
  const cacheKey = MemoryCache.key(query, variables);
@@ -1534,20 +1595,29 @@ var AniListClient = class {
1534
1595
  const start = Date.now();
1535
1596
  this.hooks.onRequest?.(query, variables);
1536
1597
  const minifiedQuery = normalizeQuery(query);
1537
- const res = await this.rateLimiter.fetchWithRetry(
1538
- this.apiUrl,
1539
- {
1540
- method: "POST",
1541
- headers: this.headers,
1542
- body: JSON.stringify({ query: minifiedQuery, variables }),
1543
- signal: this.signal
1544
- },
1545
- { onRetry: this.hooks.onRetry, onRateLimit: this.hooks.onRateLimit }
1546
- );
1598
+ let res;
1599
+ try {
1600
+ res = await this.rateLimiter.fetchWithRetry(
1601
+ this.apiUrl,
1602
+ {
1603
+ method: "POST",
1604
+ headers: this.headers,
1605
+ body: JSON.stringify({ query: minifiedQuery, variables }),
1606
+ signal: this.signal
1607
+ },
1608
+ { onRetry: this.hooks.onRetry, onRateLimit: this.hooks.onRateLimit }
1609
+ );
1610
+ } catch (err) {
1611
+ const error = err instanceof AniListError ? err : new AniListError(err.message ?? "Network request failed", 0, [err]);
1612
+ this.hooks.onError?.(error, query, variables);
1613
+ throw error;
1614
+ }
1547
1615
  const json = await res.json();
1548
1616
  if (!res.ok || json.errors) {
1549
1617
  const message = json.errors?.[0]?.message ?? `AniList API error (HTTP ${res.status})`;
1550
- throw new AniListError(message, res.status, json.errors ?? []);
1618
+ const error = new AniListError(message, res.status, json.errors ?? []);
1619
+ this.hooks.onError?.(error, query, variables);
1620
+ throw error;
1551
1621
  }
1552
1622
  const rlLimit = res.headers.get("X-RateLimit-Limit");
1553
1623
  const rlRemaining = res.headers.get("X-RateLimit-Remaining");
@@ -1576,7 +1646,6 @@ var AniListClient = class {
1576
1646
  }
1577
1647
  return { pageInfo: data.Page.pageInfo, results };
1578
1648
  }
1579
- // ── Media ──
1580
1649
  /**
1581
1650
  * Fetch a single media entry by its AniList ID.
1582
1651
  *
@@ -1633,7 +1702,6 @@ var AniListClient = class {
1633
1702
  async getMediaBySeason(options) {
1634
1703
  return getMediaBySeason(this, options);
1635
1704
  }
1636
- // ── Characters ──
1637
1705
  /** Fetch a character by AniList ID. Pass `{ voiceActors: true }` to include VA data. */
1638
1706
  async getCharacter(id, include) {
1639
1707
  return getCharacter(this, id, include);
@@ -1642,7 +1710,6 @@ var AniListClient = class {
1642
1710
  async searchCharacters(options = {}) {
1643
1711
  return searchCharacters(this, options);
1644
1712
  }
1645
- // ── Staff ──
1646
1713
  /** Fetch a staff member by AniList ID. Pass `{ media: true }` or `{ media: { perPage } }` for media credits. */
1647
1714
  async getStaff(id, include) {
1648
1715
  return getStaff(this, id, include);
@@ -1651,7 +1718,6 @@ var AniListClient = class {
1651
1718
  async searchStaff(options = {}) {
1652
1719
  return searchStaff(this, options);
1653
1720
  }
1654
- // ── Users ──
1655
1721
  /**
1656
1722
  * Fetch a user by AniList ID or username.
1657
1723
  *
@@ -1683,7 +1749,6 @@ var AniListClient = class {
1683
1749
  async getUserFavorites(idOrName) {
1684
1750
  return getUserFavorites(this, idOrName);
1685
1751
  }
1686
- // ── Studios ──
1687
1752
  /** Fetch a studio by its AniList ID. */
1688
1753
  async getStudio(id) {
1689
1754
  return getStudio(this, id);
@@ -1692,8 +1757,6 @@ var AniListClient = class {
1692
1757
  async searchStudios(options = {}) {
1693
1758
  return searchStudios(this, options);
1694
1759
  }
1695
- // ── Metadata ──
1696
- // ── Threads ──
1697
1760
  /** Fetch a forum thread by its AniList ID. */
1698
1761
  async getThread(id) {
1699
1762
  return getThread(this, id);
@@ -1712,12 +1775,10 @@ var AniListClient = class {
1712
1775
  const data = await this.request(QUERY_TAGS);
1713
1776
  return data.MediaTagCollection;
1714
1777
  }
1715
- // ── Raw query ──
1716
1778
  /** Execute an arbitrary GraphQL query against the AniList API. */
1717
1779
  async raw(query, variables) {
1718
1780
  return this.request(query, variables ?? {});
1719
1781
  }
1720
- // ── Pagination ──
1721
1782
  /**
1722
1783
  * Auto-paginating async iterator. Yields individual items across all pages.
1723
1784
  *
@@ -1736,7 +1797,6 @@ var AniListClient = class {
1736
1797
  page++;
1737
1798
  }
1738
1799
  }
1739
- // ── Batch queries ──
1740
1800
  /** Fetch multiple media entries in a single API request. */
1741
1801
  async getMediaBatch(ids) {
1742
1802
  if (ids.length === 0) return [];
@@ -1770,13 +1830,12 @@ var AniListClient = class {
1770
1830
  );
1771
1831
  return chunkResults.flat();
1772
1832
  }
1773
- // ── Cache management ──
1774
1833
  /** Clear the entire response cache. */
1775
1834
  async clearCache() {
1776
1835
  await this.cacheAdapter.clear();
1777
1836
  }
1778
1837
  /** Number of entries currently in the cache. */
1779
- get cacheSize() {
1838
+ async cacheSize() {
1780
1839
  return this.cacheAdapter.size;
1781
1840
  }
1782
1841
  /** Remove cache entries whose key matches the given pattern. */
@@ -1799,6 +1858,7 @@ var AniListClient = class {
1799
1858
  async destroy() {
1800
1859
  await this.cacheAdapter.clear();
1801
1860
  this.inFlight.clear();
1861
+ this.rateLimiter.dispose();
1802
1862
  }
1803
1863
  };
1804
1864