@gravito/constellation 3.0.1 → 3.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.
package/dist/index.js CHANGED
@@ -1,35 +1,84 @@
1
1
  import {
2
- DiskSitemapStorage
3
- } from "./chunk-7WHLC3OJ.js";
2
+ DiskSitemapStorage,
3
+ compressToBuffer,
4
+ createCompressionStream,
5
+ fromGzipFilename,
6
+ toGzipFilename
7
+ } from "./chunk-3IZTXYU7.js";
4
8
 
5
9
  // src/core/ChangeTracker.ts
6
10
  var MemoryChangeTracker = class {
7
11
  changes = [];
12
+ urlIndex = /* @__PURE__ */ new Map();
8
13
  maxChanges;
9
14
  constructor(options = {}) {
10
15
  this.maxChanges = options.maxChanges || 1e5;
11
16
  }
17
+ /**
18
+ * Record a new site structure change in memory.
19
+ *
20
+ * @param change - The change event to record.
21
+ */
12
22
  async track(change) {
13
23
  this.changes.push(change);
24
+ const urlChanges = this.urlIndex.get(change.url) || [];
25
+ urlChanges.push(change);
26
+ this.urlIndex.set(change.url, urlChanges);
14
27
  if (this.changes.length > this.maxChanges) {
15
- this.changes = this.changes.slice(-this.maxChanges);
28
+ const removed = this.changes.shift();
29
+ if (removed) {
30
+ const changes = this.urlIndex.get(removed.url);
31
+ if (changes) {
32
+ const index = changes.indexOf(removed);
33
+ if (index > -1) {
34
+ changes.splice(index, 1);
35
+ }
36
+ if (changes.length === 0) {
37
+ this.urlIndex.delete(removed.url);
38
+ }
39
+ }
40
+ }
16
41
  }
17
42
  }
43
+ /**
44
+ * Retrieve all changes recorded in memory since a specific time.
45
+ *
46
+ * @param since - Optional start date for the query.
47
+ * @returns An array of change events.
48
+ */
18
49
  async getChanges(since) {
19
50
  if (!since) {
20
51
  return [...this.changes];
21
52
  }
22
53
  return this.changes.filter((change) => change.timestamp >= since);
23
54
  }
55
+ /**
56
+ * Retrieve the full change history for a specific URL from memory.
57
+ *
58
+ * @param url - The URL to query history for.
59
+ * @returns An array of change events.
60
+ */
24
61
  async getChangesByUrl(url) {
25
- return this.changes.filter((change) => change.url === url);
62
+ return this.urlIndex.get(url) || [];
26
63
  }
64
+ /**
65
+ * Purge old change records from memory storage.
66
+ *
67
+ * @param since - If provided, only records older than this date will be cleared.
68
+ */
27
69
  async clear(since) {
28
70
  if (!since) {
29
71
  this.changes = [];
72
+ this.urlIndex.clear();
30
73
  return;
31
74
  }
32
75
  this.changes = this.changes.filter((change) => change.timestamp < since);
76
+ this.urlIndex.clear();
77
+ for (const change of this.changes) {
78
+ const urlChanges = this.urlIndex.get(change.url) || [];
79
+ urlChanges.push(change);
80
+ this.urlIndex.set(change.url, urlChanges);
81
+ }
33
82
  }
34
83
  };
35
84
  var RedisChangeTracker = class {
@@ -47,6 +96,11 @@ var RedisChangeTracker = class {
47
96
  getListKey() {
48
97
  return `${this.keyPrefix}list`;
49
98
  }
99
+ /**
100
+ * Record a new site structure change in Redis.
101
+ *
102
+ * @param change - The change event to record.
103
+ */
50
104
  async track(change) {
51
105
  const key = this.getKey(change.url);
52
106
  const listKey = this.getListKey();
@@ -56,6 +110,12 @@ var RedisChangeTracker = class {
56
110
  await this.client.zadd(listKey, score, change.url);
57
111
  await this.client.expire(listKey, this.ttl);
58
112
  }
113
+ /**
114
+ * Retrieve all changes recorded in Redis since a specific time.
115
+ *
116
+ * @param since - Optional start date for the query.
117
+ * @returns An array of change events.
118
+ */
59
119
  async getChanges(since) {
60
120
  try {
61
121
  const listKey = this.getListKey();
@@ -76,6 +136,12 @@ var RedisChangeTracker = class {
76
136
  return [];
77
137
  }
78
138
  }
139
+ /**
140
+ * Retrieve the full change history for a specific URL from Redis.
141
+ *
142
+ * @param url - The URL to query history for.
143
+ * @returns An array of change events.
144
+ */
79
145
  async getChangesByUrl(url) {
80
146
  try {
81
147
  const key = this.getKey(url);
@@ -90,6 +156,11 @@ var RedisChangeTracker = class {
90
156
  return [];
91
157
  }
92
158
  }
159
+ /**
160
+ * Purge old change records from Redis storage.
161
+ *
162
+ * @param since - If provided, only records older than this date will be cleared.
163
+ */
93
164
  async clear(since) {
94
165
  try {
95
166
  const listKey = this.getListKey();
@@ -119,7 +190,11 @@ var DiffCalculator = class {
119
190
  this.batchSize = options.batchSize || 1e4;
120
191
  }
121
192
  /**
122
- * 計算兩個 sitemap 狀態的差異
193
+ * Calculates the difference between two sets of sitemap entries.
194
+ *
195
+ * @param oldEntries - The previous set of sitemap entries.
196
+ * @param newEntries - The current set of sitemap entries.
197
+ * @returns A DiffResult containing added, updated, and removed entries.
123
198
  */
124
199
  calculate(oldEntries, newEntries) {
125
200
  const oldMap = /* @__PURE__ */ new Map();
@@ -149,7 +224,11 @@ var DiffCalculator = class {
149
224
  return { added, updated, removed };
150
225
  }
151
226
  /**
152
- * 批次計算差異(用於大量 URL)
227
+ * Batch calculates differences for large datasets using async iterables.
228
+ *
229
+ * @param oldEntries - An async iterable of the previous sitemap entries.
230
+ * @param newEntries - An async iterable of the current sitemap entries.
231
+ * @returns A promise resolving to the DiffResult.
153
232
  */
154
233
  async calculateBatch(oldEntries, newEntries) {
155
234
  const oldMap = /* @__PURE__ */ new Map();
@@ -163,7 +242,11 @@ var DiffCalculator = class {
163
242
  return this.calculate(Array.from(oldMap.values()), Array.from(newMap.values()));
164
243
  }
165
244
  /**
166
- * 從變更記錄計算差異
245
+ * Calculates differences based on a sequence of change records.
246
+ *
247
+ * @param baseEntries - The base set of sitemap entries.
248
+ * @param changes - An array of change records to apply to the base set.
249
+ * @returns A DiffResult comparing the base set with the applied changes.
167
250
  */
168
251
  calculateFromChanges(baseEntries, changes) {
169
252
  const entryMap = /* @__PURE__ */ new Map();
@@ -183,7 +266,11 @@ var DiffCalculator = class {
183
266
  return this.calculate(baseEntries, newEntries);
184
267
  }
185
268
  /**
186
- * 檢查 entry 是否有變更
269
+ * Checks if a sitemap entry has changed by comparing its key properties.
270
+ *
271
+ * @param oldEntry - The previous sitemap entry.
272
+ * @param newEntry - The current sitemap entry.
273
+ * @returns True if the entry has changed, false otherwise.
187
274
  */
188
275
  hasChanged(oldEntry, newEntry) {
189
276
  if (oldEntry.lastmod !== newEntry.lastmod) {
@@ -204,55 +291,89 @@ var DiffCalculator = class {
204
291
  }
205
292
  };
206
293
 
294
+ // src/utils/Mutex.ts
295
+ var Mutex = class {
296
+ queue = Promise.resolve();
297
+ /**
298
+ * Executes a function exclusively, ensuring no other task can run it concurrently.
299
+ *
300
+ * @param fn - The async function to execute.
301
+ * @returns A promise resolving to the result of the function.
302
+ */
303
+ async runExclusive(fn) {
304
+ const next = this.queue.then(() => fn());
305
+ this.queue = next.then(
306
+ () => {
307
+ },
308
+ () => {
309
+ }
310
+ );
311
+ return next;
312
+ }
313
+ };
314
+
207
315
  // src/core/ShadowProcessor.ts
208
316
  var ShadowProcessor = class {
209
317
  options;
210
318
  shadowId;
211
319
  operations = [];
320
+ mutex = new Mutex();
212
321
  constructor(options) {
213
322
  this.options = options;
214
- this.shadowId = `shadow-${Date.now()}-${Math.random().toString(36).substring(7)}`;
323
+ this.shadowId = `shadow-${Date.now()}-${crypto.randomUUID()}`;
215
324
  }
216
325
  /**
217
- * 添加一個影子操作
326
+ * Adds a single file write operation to the current shadow session.
327
+ *
328
+ * If shadow processing is disabled, the file is written directly to the
329
+ * final destination in storage. Otherwise, it is written to the shadow staging area.
330
+ *
331
+ * @param operation - The shadow write operation details.
218
332
  */
219
333
  async addOperation(operation) {
220
- if (!this.options.enabled) {
221
- await this.options.storage.write(operation.filename, operation.content);
222
- return;
223
- }
224
- this.operations.push({
225
- ...operation,
226
- shadowId: operation.shadowId || this.shadowId
334
+ return this.mutex.runExclusive(async () => {
335
+ if (!this.options.enabled) {
336
+ await this.options.storage.write(operation.filename, operation.content);
337
+ return;
338
+ }
339
+ this.operations.push({
340
+ ...operation,
341
+ shadowId: operation.shadowId || this.shadowId
342
+ });
343
+ if (this.options.storage.writeShadow) {
344
+ await this.options.storage.writeShadow(operation.filename, operation.content, this.shadowId);
345
+ } else {
346
+ await this.options.storage.write(operation.filename, operation.content);
347
+ }
227
348
  });
228
- if (this.options.storage.writeShadow) {
229
- await this.options.storage.writeShadow(operation.filename, operation.content, this.shadowId);
230
- } else {
231
- await this.options.storage.write(operation.filename, operation.content);
232
- }
233
349
  }
234
350
  /**
235
- * 提交所有影子操作
351
+ * Commits all staged shadow operations to the final production location.
352
+ *
353
+ * Depending on the `mode`, this will either perform an atomic swap of all files
354
+ * or commit each file individually (potentially creating new versions).
236
355
  */
237
356
  async commit() {
238
- if (!this.options.enabled) {
239
- return;
240
- }
241
- if (this.options.mode === "atomic") {
242
- if (this.options.storage.commitShadow) {
243
- await this.options.storage.commitShadow(this.shadowId);
357
+ return this.mutex.runExclusive(async () => {
358
+ if (!this.options.enabled) {
359
+ return;
244
360
  }
245
- } else {
246
- for (const operation of this.operations) {
361
+ if (this.options.mode === "atomic") {
247
362
  if (this.options.storage.commitShadow) {
248
- await this.options.storage.commitShadow(operation.shadowId || this.shadowId);
363
+ await this.options.storage.commitShadow(this.shadowId);
364
+ }
365
+ } else {
366
+ for (const operation of this.operations) {
367
+ if (this.options.storage.commitShadow) {
368
+ await this.options.storage.commitShadow(operation.shadowId || this.shadowId);
369
+ }
249
370
  }
250
371
  }
251
- }
252
- this.operations = [];
372
+ this.operations = [];
373
+ });
253
374
  }
254
375
  /**
255
- * 取消所有影子操作
376
+ * Cancels all staged shadow operations without committing them.
256
377
  */
257
378
  async rollback() {
258
379
  if (!this.options.enabled) {
@@ -261,13 +382,13 @@ var ShadowProcessor = class {
261
382
  this.operations = [];
262
383
  }
263
384
  /**
264
- * 獲取當前影子 ID
385
+ * Returns the unique identifier for the current shadow session.
265
386
  */
266
387
  getShadowId() {
267
388
  return this.shadowId;
268
389
  }
269
390
  /**
270
- * 獲取所有操作
391
+ * Returns an array of all staged shadow operations.
271
392
  */
272
393
  getOperations() {
273
394
  return [...this.operations];
@@ -284,6 +405,12 @@ var SitemapIndex = class {
284
405
  this.options.baseUrl = this.options.baseUrl.slice(0, -1);
285
406
  }
286
407
  }
408
+ /**
409
+ * Adds a single entry to the sitemap index.
410
+ *
411
+ * @param entry - A sitemap filename or a `SitemapIndexEntry` object.
412
+ * @returns The `SitemapIndex` instance for chaining.
413
+ */
287
414
  add(entry) {
288
415
  if (typeof entry === "string") {
289
416
  this.entries.push({ url: entry });
@@ -292,12 +419,23 @@ var SitemapIndex = class {
292
419
  }
293
420
  return this;
294
421
  }
422
+ /**
423
+ * Adds multiple entries to the sitemap index.
424
+ *
425
+ * @param entries - An array of sitemap filenames or `SitemapIndexEntry` objects.
426
+ * @returns The `SitemapIndex` instance for chaining.
427
+ */
295
428
  addAll(entries) {
296
429
  for (const entry of entries) {
297
430
  this.add(entry);
298
431
  }
299
432
  return this;
300
433
  }
434
+ /**
435
+ * Generates the sitemap index XML content.
436
+ *
437
+ * @returns The complete XML string for the sitemap index.
438
+ */
301
439
  toXML() {
302
440
  const { baseUrl, pretty } = this.options;
303
441
  let xml = `<?xml version="1.0" encoding="UTF-8"?>
@@ -326,6 +464,9 @@ var SitemapIndex = class {
326
464
  xml += `</sitemapindex>`;
327
465
  return xml;
328
466
  }
467
+ /**
468
+ * Escapes special XML characters in a string.
469
+ */
329
470
  escape(str) {
330
471
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
331
472
  }
@@ -335,55 +476,137 @@ var SitemapIndex = class {
335
476
  var SitemapStream = class {
336
477
  options;
337
478
  entries = [];
479
+ flags = {
480
+ hasImages: false,
481
+ hasVideos: false,
482
+ hasNews: false,
483
+ hasAlternates: false
484
+ };
338
485
  constructor(options) {
339
486
  this.options = { ...options };
340
487
  if (this.options.baseUrl.endsWith("/")) {
341
488
  this.options.baseUrl = this.options.baseUrl.slice(0, -1);
342
489
  }
343
490
  }
491
+ /**
492
+ * Adds a single entry to the sitemap stream.
493
+ *
494
+ * @param entry - A URL string or a complete `SitemapEntry` object.
495
+ * @returns The `SitemapStream` instance for chaining.
496
+ */
344
497
  add(entry) {
345
- if (typeof entry === "string") {
346
- this.entries.push({ url: entry });
347
- } else {
348
- this.entries.push(entry);
498
+ const e = typeof entry === "string" ? { url: entry } : entry;
499
+ this.entries.push(e);
500
+ if (e.images && e.images.length > 0) {
501
+ this.flags.hasImages = true;
502
+ }
503
+ if (e.videos && e.videos.length > 0) {
504
+ this.flags.hasVideos = true;
505
+ }
506
+ if (e.news) {
507
+ this.flags.hasNews = true;
508
+ }
509
+ if (e.alternates && e.alternates.length > 0) {
510
+ this.flags.hasAlternates = true;
349
511
  }
350
512
  return this;
351
513
  }
514
+ /**
515
+ * Adds multiple entries to the sitemap stream.
516
+ *
517
+ * @param entries - An array of URL strings or `SitemapEntry` objects.
518
+ * @returns The `SitemapStream` instance for chaining.
519
+ */
352
520
  addAll(entries) {
353
521
  for (const entry of entries) {
354
522
  this.add(entry);
355
523
  }
356
524
  return this;
357
525
  }
526
+ /**
527
+ * Generates the sitemap XML content.
528
+ *
529
+ * Automatically includes the necessary XML namespaces for images, videos, news,
530
+ * and internationalization if the entries contain such metadata.
531
+ *
532
+ * @returns The complete XML string for the sitemap.
533
+ */
358
534
  toXML() {
535
+ const parts = [];
536
+ for (const chunk of this.toSyncIterable()) {
537
+ parts.push(chunk);
538
+ }
539
+ return parts.join("");
540
+ }
541
+ /**
542
+ * 以 AsyncGenerator 方式產生 XML 內容。
543
+ * 每次 yield 一個邏輯區塊,適合串流寫入場景,可減少記憶體峰值。
544
+ *
545
+ * @returns AsyncGenerator 產生 XML 字串片段
546
+ *
547
+ * @example
548
+ * ```typescript
549
+ * const stream = new SitemapStream({ baseUrl: 'https://example.com' })
550
+ * stream.add({ url: '/page1' })
551
+ * stream.add({ url: '/page2' })
552
+ *
553
+ * for await (const chunk of stream.toAsyncIterable()) {
554
+ * process.stdout.write(chunk)
555
+ * }
556
+ * ```
557
+ *
558
+ * @since 3.1.0
559
+ */
560
+ async *toAsyncIterable() {
561
+ yield '<?xml version="1.0" encoding="UTF-8"?>\n';
562
+ yield this.buildUrlsetOpenTag();
359
563
  const { baseUrl, pretty } = this.options;
360
- let xml = `<?xml version="1.0" encoding="UTF-8"?>
361
- `;
362
- xml += `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"`;
363
- if (this.hasImages()) {
364
- xml += ` xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"`;
564
+ for (const entry of this.entries) {
565
+ yield this.renderUrl(entry, baseUrl, pretty);
365
566
  }
366
- if (this.hasVideos()) {
367
- xml += ` xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"`;
567
+ yield "</urlset>";
568
+ }
569
+ /**
570
+ * 同步版本的 iterable,供 toXML() 使用。
571
+ */
572
+ *toSyncIterable() {
573
+ yield '<?xml version="1.0" encoding="UTF-8"?>\n';
574
+ yield this.buildUrlsetOpenTag();
575
+ const { baseUrl, pretty } = this.options;
576
+ for (const entry of this.entries) {
577
+ yield this.renderUrl(entry, baseUrl, pretty);
368
578
  }
369
- if (this.hasNews()) {
370
- xml += ` xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"`;
579
+ yield "</urlset>";
580
+ }
581
+ /**
582
+ * 建立 urlset 開標籤與所有必要的 XML 命名空間。
583
+ */
584
+ buildUrlsetOpenTag() {
585
+ const parts = [];
586
+ parts.push('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"');
587
+ if (this.flags.hasImages) {
588
+ parts.push(' xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"');
371
589
  }
372
- if (this.hasAlternates()) {
373
- xml += ` xmlns:xhtml="http://www.w3.org/1999/xhtml"`;
590
+ if (this.flags.hasVideos) {
591
+ parts.push(' xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"');
374
592
  }
375
- xml += `>
376
- `;
377
- for (const entry of this.entries) {
378
- xml += this.renderUrl(entry, baseUrl, pretty);
593
+ if (this.flags.hasNews) {
594
+ parts.push(' xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"');
379
595
  }
380
- xml += `</urlset>`;
381
- return xml;
596
+ if (this.flags.hasAlternates) {
597
+ parts.push(' xmlns:xhtml="http://www.w3.org/1999/xhtml"');
598
+ }
599
+ parts.push(">\n");
600
+ return parts.join("");
382
601
  }
602
+ /**
603
+ * Renders a single sitemap entry into its XML representation.
604
+ */
383
605
  renderUrl(entry, baseUrl, pretty) {
384
606
  const indent = pretty ? " " : "";
385
607
  const subIndent = pretty ? " " : "";
386
608
  const nl = pretty ? "\n" : "";
609
+ const parts = [];
387
610
  let loc = entry.url;
388
611
  if (!loc.startsWith("http")) {
389
612
  if (!loc.startsWith("/")) {
@@ -391,17 +614,17 @@ var SitemapStream = class {
391
614
  }
392
615
  loc = baseUrl + loc;
393
616
  }
394
- let item = `${indent}<url>${nl}`;
395
- item += `${subIndent}<loc>${this.escape(loc)}</loc>${nl}`;
617
+ parts.push(`${indent}<url>${nl}`);
618
+ parts.push(`${subIndent}<loc>${this.escape(loc)}</loc>${nl}`);
396
619
  if (entry.lastmod) {
397
620
  const date = entry.lastmod instanceof Date ? entry.lastmod : new Date(entry.lastmod);
398
- item += `${subIndent}<lastmod>${date.toISOString().split("T")[0]}</lastmod>${nl}`;
621
+ parts.push(`${subIndent}<lastmod>${date.toISOString().split("T")[0]}</lastmod>${nl}`);
399
622
  }
400
623
  if (entry.changefreq) {
401
- item += `${subIndent}<changefreq>${entry.changefreq}</changefreq>${nl}`;
624
+ parts.push(`${subIndent}<changefreq>${entry.changefreq}</changefreq>${nl}`);
402
625
  }
403
626
  if (entry.priority !== void 0) {
404
- item += `${subIndent}<priority>${entry.priority.toFixed(1)}</priority>${nl}`;
627
+ parts.push(`${subIndent}<priority>${entry.priority.toFixed(1)}</priority>${nl}`);
405
628
  }
406
629
  if (entry.alternates) {
407
630
  for (const alt of entry.alternates) {
@@ -412,7 +635,9 @@ var SitemapStream = class {
412
635
  }
413
636
  altLoc = baseUrl + altLoc;
414
637
  }
415
- item += `${subIndent}<xhtml:link rel="alternate" hreflang="${alt.lang}" href="${this.escape(altLoc)}"/>${nl}`;
638
+ parts.push(
639
+ `${subIndent}<xhtml:link rel="alternate" hreflang="${alt.lang}" href="${this.escape(altLoc)}"/>${nl}`
640
+ );
416
641
  }
417
642
  }
418
643
  if (entry.redirect?.canonical) {
@@ -423,10 +648,14 @@ var SitemapStream = class {
423
648
  }
424
649
  canonicalUrl = baseUrl + canonicalUrl;
425
650
  }
426
- item += `${subIndent}<xhtml:link rel="canonical" href="${this.escape(canonicalUrl)}"/>${nl}`;
651
+ parts.push(
652
+ `${subIndent}<xhtml:link rel="canonical" href="${this.escape(canonicalUrl)}"/>${nl}`
653
+ );
427
654
  }
428
655
  if (entry.redirect && !entry.redirect.canonical) {
429
- item += `${subIndent}<!-- Redirect: ${entry.redirect.from} \u2192 ${entry.redirect.to} (${entry.redirect.type}) -->${nl}`;
656
+ parts.push(
657
+ `${subIndent}<!-- Redirect: ${entry.redirect.from} \u2192 ${entry.redirect.to} (${entry.redirect.type}) -->${nl}`
658
+ );
430
659
  }
431
660
  if (entry.images) {
432
661
  for (const img of entry.images) {
@@ -437,91 +666,118 @@ var SitemapStream = class {
437
666
  }
438
667
  loc2 = baseUrl + loc2;
439
668
  }
440
- item += `${subIndent}<image:image>${nl}`;
441
- item += `${subIndent} <image:loc>${this.escape(loc2)}</image:loc>${nl}`;
669
+ parts.push(`${subIndent}<image:image>${nl}`);
670
+ parts.push(`${subIndent} <image:loc>${this.escape(loc2)}</image:loc>${nl}`);
442
671
  if (img.title) {
443
- item += `${subIndent} <image:title>${this.escape(img.title)}</image:title>${nl}`;
672
+ parts.push(`${subIndent} <image:title>${this.escape(img.title)}</image:title>${nl}`);
444
673
  }
445
674
  if (img.caption) {
446
- item += `${subIndent} <image:caption>${this.escape(img.caption)}</image:caption>${nl}`;
675
+ parts.push(
676
+ `${subIndent} <image:caption>${this.escape(img.caption)}</image:caption>${nl}`
677
+ );
447
678
  }
448
679
  if (img.geo_location) {
449
- item += `${subIndent} <image:geo_location>${this.escape(img.geo_location)}</image:geo_location>${nl}`;
680
+ parts.push(
681
+ `${subIndent} <image:geo_location>${this.escape(img.geo_location)}</image:geo_location>${nl}`
682
+ );
450
683
  }
451
684
  if (img.license) {
452
- item += `${subIndent} <image:license>${this.escape(img.license)}</image:license>${nl}`;
685
+ parts.push(
686
+ `${subIndent} <image:license>${this.escape(img.license)}</image:license>${nl}`
687
+ );
453
688
  }
454
- item += `${subIndent}</image:image>${nl}`;
689
+ parts.push(`${subIndent}</image:image>${nl}`);
455
690
  }
456
691
  }
457
692
  if (entry.videos) {
458
693
  for (const video of entry.videos) {
459
- item += `${subIndent}<video:video>${nl}`;
460
- item += `${subIndent} <video:thumbnail_loc>${this.escape(video.thumbnail_loc)}</video:thumbnail_loc>${nl}`;
461
- item += `${subIndent} <video:title>${this.escape(video.title)}</video:title>${nl}`;
462
- item += `${subIndent} <video:description>${this.escape(video.description)}</video:description>${nl}`;
694
+ parts.push(`${subIndent}<video:video>${nl}`);
695
+ parts.push(
696
+ `${subIndent} <video:thumbnail_loc>${this.escape(video.thumbnail_loc)}</video:thumbnail_loc>${nl}`
697
+ );
698
+ parts.push(`${subIndent} <video:title>${this.escape(video.title)}</video:title>${nl}`);
699
+ parts.push(
700
+ `${subIndent} <video:description>${this.escape(video.description)}</video:description>${nl}`
701
+ );
463
702
  if (video.content_loc) {
464
- item += `${subIndent} <video:content_loc>${this.escape(video.content_loc)}</video:content_loc>${nl}`;
703
+ parts.push(
704
+ `${subIndent} <video:content_loc>${this.escape(video.content_loc)}</video:content_loc>${nl}`
705
+ );
465
706
  }
466
707
  if (video.player_loc) {
467
- item += `${subIndent} <video:player_loc>${this.escape(video.player_loc)}</video:player_loc>${nl}`;
708
+ parts.push(
709
+ `${subIndent} <video:player_loc>${this.escape(video.player_loc)}</video:player_loc>${nl}`
710
+ );
468
711
  }
469
712
  if (video.duration) {
470
- item += `${subIndent} <video:duration>${video.duration}</video:duration>${nl}`;
713
+ parts.push(`${subIndent} <video:duration>${video.duration}</video:duration>${nl}`);
471
714
  }
472
715
  if (video.view_count) {
473
- item += `${subIndent} <video:view_count>${video.view_count}</video:view_count>${nl}`;
716
+ parts.push(`${subIndent} <video:view_count>${video.view_count}</video:view_count>${nl}`);
474
717
  }
475
718
  if (video.publication_date) {
476
719
  const pubDate = video.publication_date instanceof Date ? video.publication_date : new Date(video.publication_date);
477
- item += `${subIndent} <video:publication_date>${pubDate.toISOString()}</video:publication_date>${nl}`;
720
+ parts.push(
721
+ `${subIndent} <video:publication_date>${pubDate.toISOString()}</video:publication_date>${nl}`
722
+ );
478
723
  }
479
724
  if (video.family_friendly) {
480
- item += `${subIndent} <video:family_friendly>${video.family_friendly}</video:family_friendly>${nl}`;
725
+ parts.push(
726
+ `${subIndent} <video:family_friendly>${video.family_friendly}</video:family_friendly>${nl}`
727
+ );
481
728
  }
482
729
  if (video.tag) {
483
730
  for (const tag of video.tag) {
484
- item += `${subIndent} <video:tag>${this.escape(tag)}</video:tag>${nl}`;
731
+ parts.push(`${subIndent} <video:tag>${this.escape(tag)}</video:tag>${nl}`);
485
732
  }
486
733
  }
487
- item += `${subIndent}</video:video>${nl}`;
734
+ parts.push(`${subIndent}</video:video>${nl}`);
488
735
  }
489
736
  }
490
737
  if (entry.news) {
491
- item += `${subIndent}<news:news>${nl}`;
492
- item += `${subIndent} <news:publication>${nl}`;
493
- item += `${subIndent} <news:name>${this.escape(entry.news.publication.name)}</news:name>${nl}`;
494
- item += `${subIndent} <news:language>${this.escape(entry.news.publication.language)}</news:language>${nl}`;
495
- item += `${subIndent} </news:publication>${nl}`;
738
+ parts.push(`${subIndent}<news:news>${nl}`);
739
+ parts.push(`${subIndent} <news:publication>${nl}`);
740
+ parts.push(
741
+ `${subIndent} <news:name>${this.escape(entry.news.publication.name)}</news:name>${nl}`
742
+ );
743
+ parts.push(
744
+ `${subIndent} <news:language>${this.escape(entry.news.publication.language)}</news:language>${nl}`
745
+ );
746
+ parts.push(`${subIndent} </news:publication>${nl}`);
496
747
  const pubDate = entry.news.publication_date instanceof Date ? entry.news.publication_date : new Date(entry.news.publication_date);
497
- item += `${subIndent} <news:publication_date>${pubDate.toISOString()}</news:publication_date>${nl}`;
498
- item += `${subIndent} <news:title>${this.escape(entry.news.title)}</news:title>${nl}`;
748
+ parts.push(
749
+ `${subIndent} <news:publication_date>${pubDate.toISOString()}</news:publication_date>${nl}`
750
+ );
751
+ parts.push(`${subIndent} <news:title>${this.escape(entry.news.title)}</news:title>${nl}`);
499
752
  if (entry.news.genres) {
500
- item += `${subIndent} <news:genres>${this.escape(entry.news.genres)}</news:genres>${nl}`;
753
+ parts.push(
754
+ `${subIndent} <news:genres>${this.escape(entry.news.genres)}</news:genres>${nl}`
755
+ );
501
756
  }
502
757
  if (entry.news.keywords) {
503
- item += `${subIndent} <news:keywords>${entry.news.keywords.map((k) => this.escape(k)).join(", ")}</news:keywords>${nl}`;
758
+ parts.push(
759
+ `${subIndent} <news:keywords>${entry.news.keywords.map((k) => this.escape(k)).join(", ")}</news:keywords>${nl}`
760
+ );
504
761
  }
505
- item += `${subIndent}</news:news>${nl}`;
762
+ parts.push(`${subIndent}</news:news>${nl}`);
506
763
  }
507
- item += `${indent}</url>${nl}`;
508
- return item;
509
- }
510
- hasImages() {
511
- return this.entries.some((e) => e.images && e.images.length > 0);
512
- }
513
- hasVideos() {
514
- return this.entries.some((e) => e.videos && e.videos.length > 0);
515
- }
516
- hasNews() {
517
- return this.entries.some((e) => !!e.news);
518
- }
519
- hasAlternates() {
520
- return this.entries.some((e) => e.alternates && e.alternates.length > 0);
764
+ parts.push(`${indent}</url>${nl}`);
765
+ return parts.join("");
521
766
  }
767
+ /**
768
+ * Escapes special XML characters in a string.
769
+ */
522
770
  escape(str) {
523
771
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
524
772
  }
773
+ /**
774
+ * Returns all entries currently in the stream.
775
+ *
776
+ * @returns An array of `SitemapEntry` objects.
777
+ */
778
+ getEntries() {
779
+ return this.entries;
780
+ }
525
781
  };
526
782
 
527
783
  // src/core/SitemapGenerator.ts
@@ -542,6 +798,12 @@ var SitemapGenerator = class {
542
798
  });
543
799
  }
544
800
  }
801
+ /**
802
+ * Orchestrates the sitemap generation process.
803
+ *
804
+ * This method scans all providers, handles sharding, generates the XML files,
805
+ * and optionally creates a sitemap index and manifest.
806
+ */
545
807
  async run() {
546
808
  let shardIndex = 1;
547
809
  let currentCount = 0;
@@ -553,18 +815,29 @@ var SitemapGenerator = class {
553
815
  baseUrl: this.options.baseUrl,
554
816
  pretty: this.options.pretty
555
817
  });
818
+ const shards = [];
556
819
  let isMultiFile = false;
557
820
  const flushShard = async () => {
558
821
  isMultiFile = true;
559
822
  const baseName = this.options.filename?.replace(/\.xml$/, "");
560
823
  const filename = `${baseName}-${shardIndex}.xml`;
561
- const xml = currentStream.toXML();
824
+ const entries = currentStream.getEntries();
825
+ let actualFilename;
562
826
  if (this.shadowProcessor) {
563
- await this.shadowProcessor.addOperation({ filename, content: xml });
827
+ actualFilename = this.options.compression?.enabled ? toGzipFilename(filename) : filename;
828
+ const xml = currentStream.toXML();
829
+ await this.shadowProcessor.addOperation({ filename: actualFilename, content: xml });
564
830
  } else {
565
- await this.options.storage.write(filename, xml);
831
+ actualFilename = await this.writeSitemap(currentStream, filename);
566
832
  }
567
- const url = this.options.storage.getUrl(filename);
833
+ shards.push({
834
+ filename: actualFilename,
835
+ from: this.normalizeUrl(entries[0].url),
836
+ to: this.normalizeUrl(entries[entries.length - 1].url),
837
+ count: entries.length,
838
+ lastmod: /* @__PURE__ */ new Date()
839
+ });
840
+ const url = this.options.storage.getUrl(actualFilename);
568
841
  index.add({
569
842
  url,
570
843
  lastmod: /* @__PURE__ */ new Date()
@@ -596,16 +869,51 @@ var SitemapGenerator = class {
596
869
  }
597
870
  }
598
871
  }
872
+ const writeManifest = async () => {
873
+ if (!this.options.generateManifest) {
874
+ return;
875
+ }
876
+ const manifest = {
877
+ version: 1,
878
+ generatedAt: /* @__PURE__ */ new Date(),
879
+ baseUrl: this.options.baseUrl,
880
+ maxEntriesPerShard: this.options.maxEntriesPerFile,
881
+ sort: "url-lex",
882
+ shards
883
+ };
884
+ const manifestFilename = this.options.filename?.replace(/\.xml$/, "-manifest.json") || "sitemap-manifest.json";
885
+ const content = JSON.stringify(manifest, null, this.options.pretty ? 2 : 0);
886
+ if (this.shadowProcessor) {
887
+ await this.shadowProcessor.addOperation({ filename: manifestFilename, content });
888
+ } else {
889
+ await this.options.storage.write(manifestFilename, content);
890
+ }
891
+ };
599
892
  if (!isMultiFile) {
600
- const xml = currentStream.toXML();
893
+ const entries = currentStream.getEntries();
894
+ let actualFilename;
601
895
  if (this.shadowProcessor) {
896
+ actualFilename = this.options.compression?.enabled ? toGzipFilename(this.options.filename) : this.options.filename;
897
+ const xml = currentStream.toXML();
602
898
  await this.shadowProcessor.addOperation({
603
- filename: this.options.filename,
899
+ filename: actualFilename,
604
900
  content: xml
605
901
  });
902
+ } else {
903
+ actualFilename = await this.writeSitemap(currentStream, this.options.filename);
904
+ }
905
+ shards.push({
906
+ filename: actualFilename,
907
+ from: entries[0] ? this.normalizeUrl(entries[0].url) : "",
908
+ to: entries[entries.length - 1] ? this.normalizeUrl(entries[entries.length - 1].url) : "",
909
+ count: entries.length,
910
+ lastmod: /* @__PURE__ */ new Date()
911
+ });
912
+ if (this.shadowProcessor) {
913
+ await writeManifest();
606
914
  await this.shadowProcessor.commit();
607
915
  } else {
608
- await this.options.storage.write(this.options.filename, xml);
916
+ await writeManifest();
609
917
  }
610
918
  return;
611
919
  }
@@ -618,35 +926,203 @@ var SitemapGenerator = class {
618
926
  filename: this.options.filename,
619
927
  content: indexXml
620
928
  });
929
+ await writeManifest();
621
930
  await this.shadowProcessor.commit();
622
931
  } else {
623
932
  await this.options.storage.write(this.options.filename, indexXml);
933
+ await writeManifest();
624
934
  }
625
935
  }
626
936
  /**
627
- * 獲取影子處理器(如果啟用)
937
+ * 統一的 sitemap 寫入方法,優先使用串流寫入以降低記憶體使用。
938
+ *
939
+ * @param stream - SitemapStream 實例
940
+ * @param filename - 檔案名稱(不含 .gz)
941
+ * @returns 實際寫入的檔名(可能包含 .gz)
942
+ * @since 3.1.0
943
+ */
944
+ async writeSitemap(stream, filename) {
945
+ const { storage, compression } = this.options;
946
+ const compress = compression?.enabled ?? false;
947
+ if (storage.writeStream) {
948
+ await storage.writeStream(filename, stream.toAsyncIterable(), {
949
+ compress,
950
+ contentType: "application/xml"
951
+ });
952
+ } else {
953
+ const xml = stream.toXML();
954
+ if (compress) {
955
+ const buffer = await compressToBuffer(
956
+ (async function* () {
957
+ yield xml;
958
+ })(),
959
+ { level: compression?.level }
960
+ );
961
+ await storage.write(toGzipFilename(filename), buffer.toString("base64"));
962
+ } else {
963
+ await storage.write(filename, xml);
964
+ }
965
+ }
966
+ return compress ? toGzipFilename(filename) : filename;
967
+ }
968
+ /**
969
+ * Normalizes a URL to an absolute URL using the base URL.
970
+ */
971
+ normalizeUrl(url) {
972
+ if (url.startsWith("http")) {
973
+ return url;
974
+ }
975
+ const { baseUrl } = this.options;
976
+ const normalizedBase = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
977
+ const normalizedPath = url.startsWith("/") ? url : `/${url}`;
978
+ return normalizedBase + normalizedPath;
979
+ }
980
+ /**
981
+ * Returns the shadow processor instance if enabled.
628
982
  */
629
983
  getShadowProcessor() {
630
984
  return this.shadowProcessor;
631
985
  }
632
986
  };
633
987
 
988
+ // src/core/SitemapParser.ts
989
+ var SitemapParser = class {
990
+ /**
991
+ * Parses a sitemap XML string into an array of entries.
992
+ *
993
+ * @param xml - The raw sitemap XML content.
994
+ * @returns An array of `SitemapEntry` objects.
995
+ */
996
+ static parse(xml) {
997
+ const entries = [];
998
+ const urlRegex = /<url>([\s\S]*?)<\/url>/g;
999
+ let match;
1000
+ while ((match = urlRegex.exec(xml)) !== null) {
1001
+ const entry = this.parseEntry(match[1]);
1002
+ if (entry) {
1003
+ entries.push(entry);
1004
+ }
1005
+ }
1006
+ return entries;
1007
+ }
1008
+ /**
1009
+ * Parses a sitemap XML stream into an async iterable of entries.
1010
+ *
1011
+ * Useful for large sitemap files that should not be fully loaded into memory.
1012
+ *
1013
+ * @param stream - An async iterable of XML chunks.
1014
+ * @returns An async iterable of `SitemapEntry` objects.
1015
+ */
1016
+ static async *parseStream(stream) {
1017
+ let buffer = "";
1018
+ const urlRegex = /<url>([\s\S]*?)<\/url>/g;
1019
+ for await (const chunk of stream) {
1020
+ buffer += chunk;
1021
+ let match;
1022
+ while ((match = urlRegex.exec(buffer)) !== null) {
1023
+ const entry = this.parseEntry(match[1]);
1024
+ if (entry) {
1025
+ yield entry;
1026
+ }
1027
+ buffer = buffer.slice(match.index + match[0].length);
1028
+ urlRegex.lastIndex = 0;
1029
+ }
1030
+ if (buffer.length > 1024 * 1024) {
1031
+ const lastUrlStart = buffer.lastIndexOf("<url>");
1032
+ buffer = lastUrlStart !== -1 ? buffer.slice(lastUrlStart) : "";
1033
+ }
1034
+ }
1035
+ }
1036
+ /**
1037
+ * Parses a single `<url>` tag content into a `SitemapEntry`.
1038
+ */
1039
+ static parseEntry(urlContent) {
1040
+ const entry = { url: "" };
1041
+ const locMatch = /<loc>(.*?)<\/loc>/.exec(urlContent);
1042
+ if (locMatch) {
1043
+ entry.url = this.unescape(locMatch[1]);
1044
+ } else {
1045
+ return null;
1046
+ }
1047
+ const lastmodMatch = /<lastmod>(.*?)<\/lastmod>/.exec(urlContent);
1048
+ if (lastmodMatch) {
1049
+ entry.lastmod = new Date(lastmodMatch[1]);
1050
+ }
1051
+ const priorityMatch = /<priority>(.*?)<\/priority>/.exec(urlContent);
1052
+ if (priorityMatch) {
1053
+ entry.priority = parseFloat(priorityMatch[1]);
1054
+ }
1055
+ const changefreqMatch = /<changefreq>(.*?)<\/changefreq>/.exec(urlContent);
1056
+ if (changefreqMatch) {
1057
+ entry.changefreq = changefreqMatch[1];
1058
+ }
1059
+ return entry;
1060
+ }
1061
+ /**
1062
+ * Parses a sitemap index XML string into an array of sitemap URLs.
1063
+ *
1064
+ * @param xml - The raw sitemap index XML content.
1065
+ * @returns An array of sub-sitemap URLs.
1066
+ */
1067
+ static parseIndex(xml) {
1068
+ const urls = [];
1069
+ const sitemapRegex = /<sitemap>([\s\S]*?)<\/sitemap>/g;
1070
+ let match;
1071
+ while ((match = sitemapRegex.exec(xml)) !== null) {
1072
+ const content = match[1];
1073
+ const locMatch = /<loc>(.*?)<\/loc>/.exec(content);
1074
+ if (locMatch) {
1075
+ urls.push(this.unescape(locMatch[1]));
1076
+ }
1077
+ }
1078
+ return urls;
1079
+ }
1080
+ /**
1081
+ * Unescapes special XML entities in a string.
1082
+ */
1083
+ static unescape(str) {
1084
+ return str.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&apos;/g, "'");
1085
+ }
1086
+ };
1087
+
634
1088
  // src/core/IncrementalGenerator.ts
635
1089
  var IncrementalGenerator = class {
636
1090
  options;
637
1091
  changeTracker;
638
1092
  diffCalculator;
639
1093
  generator;
1094
+ mutex = new Mutex();
640
1095
  constructor(options) {
641
- this.options = options;
642
- this.changeTracker = options.changeTracker;
643
- this.diffCalculator = options.diffCalculator || new DiffCalculator();
644
- this.generator = new SitemapGenerator(options);
1096
+ this.options = {
1097
+ autoTrack: true,
1098
+ generateManifest: true,
1099
+ ...options
1100
+ };
1101
+ this.changeTracker = this.options.changeTracker;
1102
+ this.diffCalculator = this.options.diffCalculator || new DiffCalculator();
1103
+ this.generator = new SitemapGenerator(this.options);
645
1104
  }
646
1105
  /**
647
- * 生成完整的 sitemap(首次生成)
1106
+ * Performs a full sitemap generation and optionally records all entries in the change tracker.
648
1107
  */
649
1108
  async generateFull() {
1109
+ return this.mutex.runExclusive(() => this.performFullGeneration());
1110
+ }
1111
+ /**
1112
+ * Performs an incremental sitemap update based on changes recorded since a specific time.
1113
+ *
1114
+ * If the number of changes exceeds a certain threshold (e.g., 30% of total URLs),
1115
+ * a full generation is triggered instead to ensure consistency.
1116
+ *
1117
+ * @param since - Optional start date for the incremental update.
1118
+ */
1119
+ async generateIncremental(since) {
1120
+ return this.mutex.runExclusive(() => this.performIncrementalGeneration(since));
1121
+ }
1122
+ /**
1123
+ * Internal implementation of full sitemap generation.
1124
+ */
1125
+ async performFullGeneration() {
650
1126
  await this.generator.run();
651
1127
  if (this.options.autoTrack) {
652
1128
  const { providers } = this.options;
@@ -665,50 +1141,149 @@ var IncrementalGenerator = class {
665
1141
  }
666
1142
  }
667
1143
  /**
668
- * 增量生成(只更新變更的部分)
1144
+ * Internal implementation of incremental sitemap generation.
669
1145
  */
670
- async generateIncremental(since) {
1146
+ async performIncrementalGeneration(since) {
671
1147
  const changes = await this.changeTracker.getChanges(since);
672
1148
  if (changes.length === 0) {
673
1149
  return;
674
1150
  }
675
- const baseEntries = await this.loadBaseEntries();
676
- const diff = this.diffCalculator.calculateFromChanges(baseEntries, changes);
677
- await this.generateDiff(diff);
1151
+ const manifest = await this.loadManifest();
1152
+ if (!manifest) {
1153
+ await this.performFullGeneration();
1154
+ return;
1155
+ }
1156
+ const totalCount = manifest.shards.reduce((acc, s) => acc + s.count, 0);
1157
+ const changeRatio = totalCount > 0 ? changes.length / totalCount : 1;
1158
+ if (changeRatio > 0.3) {
1159
+ await this.performFullGeneration();
1160
+ return;
1161
+ }
1162
+ const affectedShards = this.getAffectedShards(manifest, changes);
1163
+ if (affectedShards.size / manifest.shards.length > 0.5) {
1164
+ await this.performFullGeneration();
1165
+ return;
1166
+ }
1167
+ await this.updateShards(manifest, affectedShards);
678
1168
  }
679
1169
  /**
680
- * 手動追蹤變更
1170
+ * Normalizes a URL to an absolute URL using the base URL.
681
1171
  */
682
- async trackChange(change) {
683
- await this.changeTracker.track(change);
1172
+ normalizeUrl(url) {
1173
+ if (url.startsWith("http")) {
1174
+ return url;
1175
+ }
1176
+ const { baseUrl } = this.options;
1177
+ const normalizedBase = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
1178
+ const normalizedPath = url.startsWith("/") ? url : `/${url}`;
1179
+ return normalizedBase + normalizedPath;
684
1180
  }
685
1181
  /**
686
- * 獲取變更記錄
1182
+ * Loads the sitemap shard manifest from storage.
687
1183
  */
688
- async getChanges(since) {
689
- return this.changeTracker.getChanges(since);
1184
+ async loadManifest() {
1185
+ const filename = this.options.filename?.replace(/\.xml$/, "-manifest.json") || "sitemap-manifest.json";
1186
+ const content = await this.options.storage.read(filename);
1187
+ if (!content) {
1188
+ return null;
1189
+ }
1190
+ try {
1191
+ return JSON.parse(content);
1192
+ } catch {
1193
+ return null;
1194
+ }
690
1195
  }
691
1196
  /**
692
- * 載入基礎 entries(從現有 sitemap)
1197
+ * Identifies which shards are affected by the given set of changes.
693
1198
  */
694
- async loadBaseEntries() {
695
- const entries = [];
696
- const { providers } = this.options;
697
- for (const provider of providers) {
698
- const providerEntries = await provider.getEntries();
699
- const entriesArray = Array.isArray(providerEntries) ? providerEntries : await this.toArray(providerEntries);
700
- entries.push(...entriesArray);
1199
+ getAffectedShards(manifest, changes) {
1200
+ const affected = /* @__PURE__ */ new Map();
1201
+ for (const change of changes) {
1202
+ const normalizedUrl = this.normalizeUrl(change.url);
1203
+ let shard = manifest.shards.find((s) => {
1204
+ return normalizedUrl >= s.from && normalizedUrl <= s.to;
1205
+ });
1206
+ if (!shard) {
1207
+ shard = manifest.shards.find((s) => normalizedUrl <= s.to);
1208
+ if (!shard) {
1209
+ shard = manifest.shards[manifest.shards.length - 1];
1210
+ }
1211
+ }
1212
+ if (shard) {
1213
+ const shardChanges = affected.get(shard.filename) || [];
1214
+ shardChanges.push(change);
1215
+ affected.set(shard.filename, shardChanges);
1216
+ }
701
1217
  }
702
- return entries;
1218
+ return affected;
703
1219
  }
704
1220
  /**
705
- * 生成差異部分
1221
+ * Updates the affected shards in storage.
706
1222
  */
707
- async generateDiff(_diff) {
708
- await this.generator.run();
1223
+ async updateShards(manifest, affectedShards) {
1224
+ for (const [filename, shardChanges] of affectedShards) {
1225
+ const entries = [];
1226
+ const stream = await this.options.storage.readStream?.(filename);
1227
+ if (stream) {
1228
+ for await (const entry of SitemapParser.parseStream(stream)) {
1229
+ entries.push(entry);
1230
+ }
1231
+ } else {
1232
+ const xml = await this.options.storage.read(filename);
1233
+ if (!xml) {
1234
+ continue;
1235
+ }
1236
+ entries.push(...SitemapParser.parse(xml));
1237
+ }
1238
+ const updatedEntries = this.applyChanges(entries, shardChanges);
1239
+ const outStream = new SitemapStream({
1240
+ baseUrl: this.options.baseUrl,
1241
+ pretty: this.options.pretty
1242
+ });
1243
+ outStream.addAll(updatedEntries);
1244
+ const newXml = outStream.toXML();
1245
+ await this.options.storage.write(filename, newXml);
1246
+ const shardInfo = manifest.shards.find((s) => s.filename === filename);
1247
+ if (shardInfo) {
1248
+ shardInfo.count = updatedEntries.length;
1249
+ shardInfo.lastmod = /* @__PURE__ */ new Date();
1250
+ shardInfo.from = this.normalizeUrl(updatedEntries[0].url);
1251
+ shardInfo.to = this.normalizeUrl(updatedEntries[updatedEntries.length - 1].url);
1252
+ }
1253
+ }
1254
+ const manifestFilename = this.options.filename?.replace(/\.xml$/, "-manifest.json") || "sitemap-manifest.json";
1255
+ await this.options.storage.write(
1256
+ manifestFilename,
1257
+ JSON.stringify(manifest, null, this.options.pretty ? 2 : 0)
1258
+ );
1259
+ }
1260
+ /**
1261
+ * Applies changes to a set of sitemap entries and returns the updated, sorted list.
1262
+ */
1263
+ applyChanges(entries, changes) {
1264
+ const entryMap = /* @__PURE__ */ new Map();
1265
+ for (const entry of entries) {
1266
+ entryMap.set(this.normalizeUrl(entry.url), entry);
1267
+ }
1268
+ for (const change of changes) {
1269
+ const normalizedUrl = this.normalizeUrl(change.url);
1270
+ if (change.type === "add" || change.type === "update") {
1271
+ if (change.entry) {
1272
+ entryMap.set(normalizedUrl, {
1273
+ ...change.entry,
1274
+ url: normalizedUrl
1275
+ });
1276
+ }
1277
+ } else if (change.type === "remove") {
1278
+ entryMap.delete(normalizedUrl);
1279
+ }
1280
+ }
1281
+ return Array.from(entryMap.values()).sort(
1282
+ (a, b) => this.normalizeUrl(a.url).localeCompare(this.normalizeUrl(b.url))
1283
+ );
709
1284
  }
710
1285
  /**
711
- * AsyncIterable 轉換為陣列
1286
+ * Helper to convert an async iterable into an array.
712
1287
  */
713
1288
  async toArray(iterable) {
714
1289
  const array = [];
@@ -730,7 +1305,10 @@ var ProgressTracker = class {
730
1305
  this.updateInterval = options.updateInterval || 1e3;
731
1306
  }
732
1307
  /**
733
- * 初始化進度追蹤
1308
+ * Initializes progress tracking for a new job.
1309
+ *
1310
+ * @param jobId - Unique identifier for the generation job.
1311
+ * @param total - Total number of entries to be processed.
734
1312
  */
735
1313
  async init(jobId, total) {
736
1314
  this.currentProgress = {
@@ -744,7 +1322,13 @@ var ProgressTracker = class {
744
1322
  await this.storage.set(jobId, this.currentProgress);
745
1323
  }
746
1324
  /**
747
- * 更新進度
1325
+ * Updates the current progress of the job.
1326
+ *
1327
+ * Updates are debounced and flushed to storage at regular intervals
1328
+ * specified by `updateInterval` to avoid excessive write operations.
1329
+ *
1330
+ * @param processed - Number of entries processed so far.
1331
+ * @param status - Optional new status for the job.
748
1332
  */
749
1333
  async update(processed, status) {
750
1334
  if (!this.currentProgress) {
@@ -766,7 +1350,7 @@ var ProgressTracker = class {
766
1350
  }
767
1351
  }
768
1352
  /**
769
- * 完成進度追蹤
1353
+ * Marks the current job as successfully completed.
770
1354
  */
771
1355
  async complete() {
772
1356
  if (!this.currentProgress) {
@@ -779,7 +1363,9 @@ var ProgressTracker = class {
779
1363
  this.stop();
780
1364
  }
781
1365
  /**
782
- * 標記為失敗
1366
+ * Marks the current job as failed with an error message.
1367
+ *
1368
+ * @param error - The error message describing why the job failed.
783
1369
  */
784
1370
  async fail(error) {
785
1371
  if (!this.currentProgress) {
@@ -792,7 +1378,7 @@ var ProgressTracker = class {
792
1378
  this.stop();
793
1379
  }
794
1380
  /**
795
- * 刷新進度到儲存
1381
+ * Flushes the current progress state to the storage backend.
796
1382
  */
797
1383
  async flush() {
798
1384
  if (!this.currentProgress) {
@@ -807,7 +1393,7 @@ var ProgressTracker = class {
807
1393
  });
808
1394
  }
809
1395
  /**
810
- * 停止更新計時器
1396
+ * Stops the periodic update timer.
811
1397
  */
812
1398
  stop() {
813
1399
  if (this.updateTimer) {
@@ -816,7 +1402,9 @@ var ProgressTracker = class {
816
1402
  }
817
1403
  }
818
1404
  /**
819
- * 獲取當前進度
1405
+ * Returns a copy of the current progress state.
1406
+ *
1407
+ * @returns The current SitemapProgress object, or null if no job is active.
820
1408
  */
821
1409
  getCurrentProgress() {
822
1410
  return this.currentProgress ? { ...this.currentProgress } : null;
@@ -854,6 +1442,12 @@ var GenerateSitemapJob = class extends Job {
854
1442
  this.options = options;
855
1443
  this.generator = new SitemapGenerator(options.generatorOptions);
856
1444
  }
1445
+ /**
1446
+ * Main entry point for the job execution.
1447
+ *
1448
+ * Orchestrates the full lifecycle of sitemap generation, including progress
1449
+ * initialization, generation, shadow commit, and error handling.
1450
+ */
857
1451
  async handle() {
858
1452
  const { progressTracker, onComplete, onError } = this.options;
859
1453
  try {
@@ -884,7 +1478,9 @@ var GenerateSitemapJob = class extends Job {
884
1478
  }
885
1479
  }
886
1480
  /**
887
- * 計算總 URL
1481
+ * Calculates the total number of URL entries from all providers.
1482
+ *
1483
+ * @returns A promise resolving to the total entry count.
888
1484
  */
889
1485
  async calculateTotal() {
890
1486
  let total = 0;
@@ -899,30 +1495,580 @@ var GenerateSitemapJob = class extends Job {
899
1495
  }
900
1496
  }
901
1497
  }
902
- return total;
1498
+ return total;
1499
+ }
1500
+ /**
1501
+ * Performs sitemap generation while reporting progress to the tracker and callback.
1502
+ */
1503
+ async generateWithProgress() {
1504
+ const { progressTracker, onProgress } = this.options;
1505
+ await this.generator.run();
1506
+ this.processedEntries = this.totalEntries;
1507
+ if (progressTracker) {
1508
+ await progressTracker.update(this.processedEntries, "processing");
1509
+ }
1510
+ if (onProgress) {
1511
+ onProgress({
1512
+ processed: this.processedEntries,
1513
+ total: this.totalEntries,
1514
+ percentage: this.totalEntries > 0 ? Math.round(this.processedEntries / this.totalEntries * 100) : 100
1515
+ });
1516
+ }
1517
+ }
1518
+ };
1519
+
1520
+ // src/locks/MemoryLock.ts
1521
+ var MemoryLock = class {
1522
+ /**
1523
+ * Internal map storing resource identifiers to their lock expiration timestamps.
1524
+ *
1525
+ * Keys represent unique resource identifiers (e.g., 'sitemap-generation').
1526
+ * Values are Unix timestamps in milliseconds representing when the lock expires.
1527
+ * Expired locks are automatically cleaned up during `acquire()` and `isLocked()` calls.
1528
+ */
1529
+ locks = /* @__PURE__ */ new Map();
1530
+ /**
1531
+ * Attempts to acquire an exclusive lock on the specified resource.
1532
+ *
1533
+ * Uses a test-and-set approach: checks if the lock exists and is valid, then atomically
1534
+ * sets the lock if available. Expired locks are treated as available and automatically
1535
+ * replaced during acquisition.
1536
+ *
1537
+ * **Behavior:**
1538
+ * - Returns `true` if lock was successfully acquired
1539
+ * - Returns `false` if resource is already locked by another caller
1540
+ * - Automatically replaces expired locks (acts as self-healing mechanism)
1541
+ * - Lock automatically expires after TTL milliseconds
1542
+ *
1543
+ * **Race condition handling:**
1544
+ * Safe within a single process due to JavaScript's single-threaded event loop.
1545
+ * NOT safe across multiple processes or instances (use RedisLock for that).
1546
+ *
1547
+ * @param resource - Unique identifier for the resource to lock (e.g., 'sitemap-generation', 'blog-index').
1548
+ * Should be consistent across all callers attempting to lock the same resource.
1549
+ * @param ttl - Time-to-live in milliseconds. Lock automatically expires after this duration.
1550
+ * Recommended: 2-5x the expected operation duration to prevent premature expiration.
1551
+ * @returns Promise resolving to `true` if lock acquired, `false` if already locked.
1552
+ *
1553
+ * @example Preventing concurrent sitemap generation
1554
+ * ```typescript
1555
+ * const lock = new MemoryLock()
1556
+ * const acquired = await lock.acquire('sitemap-generation', 60000)
1557
+ *
1558
+ * if (!acquired) {
1559
+ * console.log('Another process is already generating the sitemap')
1560
+ * return new Response('Generation in progress', { status: 503 })
1561
+ * }
1562
+ *
1563
+ * try {
1564
+ * await generateSitemap()
1565
+ * } finally {
1566
+ * await lock.release('sitemap-generation')
1567
+ * }
1568
+ * ```
1569
+ *
1570
+ * @example Setting appropriate TTL
1571
+ * ```typescript
1572
+ * // For fast operations (< 1 second), use short TTL
1573
+ * await lock.acquire('cache-refresh', 5000)
1574
+ *
1575
+ * // For slow operations (minutes), use longer TTL
1576
+ * await lock.acquire('full-reindex', 300000) // 5 minutes
1577
+ * ```
1578
+ */
1579
+ async acquire(resource, ttl) {
1580
+ const now = Date.now();
1581
+ const expiresAt = this.locks.get(resource);
1582
+ if (expiresAt && expiresAt > now) {
1583
+ return false;
1584
+ }
1585
+ this.locks.set(resource, now + ttl);
1586
+ return true;
1587
+ }
1588
+ /**
1589
+ * Releases the lock on the specified resource, allowing others to acquire it.
1590
+ *
1591
+ * Immediately removes the lock from memory without any ownership validation.
1592
+ * Unlike RedisLock, this does NOT verify that the caller is the lock owner,
1593
+ * so callers must ensure they only release locks they acquired.
1594
+ *
1595
+ * **Best practices:**
1596
+ * - Always call `release()` in a `finally` block to prevent lock leakage
1597
+ * - Only release locks you successfully acquired
1598
+ * - If operation fails, still release the lock to allow retry
1599
+ *
1600
+ * **Idempotency:**
1601
+ * Safe to call multiple times on the same resource. Releasing a non-existent
1602
+ * lock is a no-op.
1603
+ *
1604
+ * @param resource - The resource identifier to unlock. Must match the identifier
1605
+ * used in the corresponding `acquire()` call.
1606
+ *
1607
+ * @example Proper release pattern with try-finally
1608
+ * ```typescript
1609
+ * const acquired = await lock.acquire('sitemap-generation', 60000)
1610
+ * if (!acquired) return
1611
+ *
1612
+ * try {
1613
+ * await generateSitemap()
1614
+ * } finally {
1615
+ * // Always release, even if operation throws
1616
+ * await lock.release('sitemap-generation')
1617
+ * }
1618
+ * ```
1619
+ *
1620
+ * @example Handling operation failures
1621
+ * ```typescript
1622
+ * const acquired = await lock.acquire('data-import', 120000)
1623
+ * if (!acquired) return
1624
+ *
1625
+ * try {
1626
+ * await importData()
1627
+ * } catch (error) {
1628
+ * console.error('Import failed:', error)
1629
+ * // Lock still released in finally block
1630
+ * throw error
1631
+ * } finally {
1632
+ * await lock.release('data-import')
1633
+ * }
1634
+ * ```
1635
+ */
1636
+ async release(resource) {
1637
+ this.locks.delete(resource);
1638
+ }
1639
+ /**
1640
+ * Checks whether a resource is currently locked and has not expired.
1641
+ *
1642
+ * Performs automatic cleanup by removing expired locks during the check,
1643
+ * ensuring the internal map doesn't accumulate stale entries over time.
1644
+ *
1645
+ * **Use cases:**
1646
+ * - Pre-flight checks before attempting expensive operations
1647
+ * - Status monitoring and health checks
1648
+ * - Implementing custom retry logic
1649
+ * - Debugging and testing
1650
+ *
1651
+ * **Side effects:**
1652
+ * Automatically deletes expired locks as a garbage collection mechanism.
1653
+ * This is intentional to prevent memory leaks from abandoned locks.
1654
+ *
1655
+ * @param resource - The resource identifier to check for lock status.
1656
+ * @returns Promise resolving to `true` if resource is actively locked (not expired),
1657
+ * `false` if unlocked or lock has expired.
1658
+ *
1659
+ * @example Pre-flight check before starting work
1660
+ * ```typescript
1661
+ * const lock = new MemoryLock()
1662
+ *
1663
+ * if (await lock.isLocked('sitemap-generation')) {
1664
+ * console.log('Sitemap generation already in progress')
1665
+ * return
1666
+ * }
1667
+ *
1668
+ * // Safe to proceed
1669
+ * await lock.acquire('sitemap-generation', 60000)
1670
+ * ```
1671
+ *
1672
+ * @example Health check endpoint
1673
+ * ```typescript
1674
+ * app.get('/health/locks', async (c) => {
1675
+ * const isGenerating = await lock.isLocked('sitemap-generation')
1676
+ * const isIndexing = await lock.isLocked('search-indexing')
1677
+ *
1678
+ * return c.json({
1679
+ * sitemapGeneration: isGenerating ? 'in-progress' : 'idle',
1680
+ * searchIndexing: isIndexing ? 'in-progress' : 'idle'
1681
+ * })
1682
+ * })
1683
+ * ```
1684
+ *
1685
+ * @example Custom retry logic
1686
+ * ```typescript
1687
+ * let attempts = 0
1688
+ * while (attempts < 5) {
1689
+ * if (!await lock.isLocked('resource')) {
1690
+ * const acquired = await lock.acquire('resource', 10000)
1691
+ * if (acquired) break
1692
+ * }
1693
+ * await sleep(1000)
1694
+ * attempts++
1695
+ * }
1696
+ * ```
1697
+ */
1698
+ async isLocked(resource) {
1699
+ const expiresAt = this.locks.get(resource);
1700
+ if (!expiresAt) {
1701
+ return false;
1702
+ }
1703
+ const now = Date.now();
1704
+ if (expiresAt <= now) {
1705
+ this.locks.delete(resource);
1706
+ return false;
1707
+ }
1708
+ return true;
1709
+ }
1710
+ /**
1711
+ * Clears all locks from memory, including both active and expired locks.
1712
+ *
1713
+ * **Use cases:**
1714
+ * - Test cleanup between test cases to ensure isolation
1715
+ * - Application shutdown to release all resources
1716
+ * - Manual intervention during debugging
1717
+ * - Resetting state after catastrophic errors
1718
+ *
1719
+ * **Warning:**
1720
+ * This forcibly releases ALL locks without any ownership validation.
1721
+ * Should not be called during normal operation in production environments.
1722
+ *
1723
+ * @example Test cleanup with beforeEach hook
1724
+ * ```typescript
1725
+ * import { describe, beforeEach, test } from 'vitest'
1726
+ *
1727
+ * const lock = new MemoryLock()
1728
+ *
1729
+ * beforeEach(async () => {
1730
+ * await lock.clear() // Ensure clean state for each test
1731
+ * })
1732
+ *
1733
+ * test('lock acquisition', async () => {
1734
+ * const acquired = await lock.acquire('test-resource', 5000)
1735
+ * expect(acquired).toBe(true)
1736
+ * })
1737
+ * ```
1738
+ *
1739
+ * @example Graceful shutdown handler
1740
+ * ```typescript
1741
+ * process.on('SIGTERM', async () => {
1742
+ * console.log('Shutting down, releasing all locks...')
1743
+ * await lock.clear()
1744
+ * process.exit(0)
1745
+ * })
1746
+ * ```
1747
+ */
1748
+ async clear() {
1749
+ this.locks.clear();
1750
+ }
1751
+ /**
1752
+ * Returns the number of lock entries currently stored in memory.
1753
+ *
1754
+ * **Important:** This includes BOTH active and expired locks. Expired locks
1755
+ * are only cleaned up during `acquire()` or `isLocked()` calls, so this count
1756
+ * may include stale entries.
1757
+ *
1758
+ * **Use cases:**
1759
+ * - Monitoring memory usage and lock accumulation
1760
+ * - Debugging lock leakage issues
1761
+ * - Testing lock lifecycle behavior
1762
+ * - Detecting abnormal lock retention patterns
1763
+ *
1764
+ * **Not suitable for:**
1765
+ * - Determining number of ACTIVE locks (use `isLocked()` on each resource)
1766
+ * - Production health checks (includes expired locks)
1767
+ *
1768
+ * @returns The total number of lock entries in the internal Map, including expired ones.
1769
+ *
1770
+ * @example Monitoring lock accumulation
1771
+ * ```typescript
1772
+ * const lock = new MemoryLock()
1773
+ *
1774
+ * setInterval(() => {
1775
+ * const count = lock.size()
1776
+ * if (count > 100) {
1777
+ * console.warn(`High lock count detected: ${count}`)
1778
+ * // May indicate lock leakage or missing release() calls
1779
+ * }
1780
+ * }, 60000)
1781
+ * ```
1782
+ *
1783
+ * @example Testing lock cleanup behavior
1784
+ * ```typescript
1785
+ * import { test, expect } from 'vitest'
1786
+ *
1787
+ * test('expired locks are cleaned up', async () => {
1788
+ * const lock = new MemoryLock()
1789
+ *
1790
+ * await lock.acquire('resource', 10)
1791
+ * expect(lock.size()).toBe(1)
1792
+ *
1793
+ * await sleep(20) // Wait for expiration
1794
+ * expect(lock.size()).toBe(1) // Still in map (not cleaned yet)
1795
+ *
1796
+ * await lock.isLocked('resource') // Triggers cleanup
1797
+ * expect(lock.size()).toBe(0) // Now removed
1798
+ * })
1799
+ * ```
1800
+ */
1801
+ size() {
1802
+ return this.locks.size;
1803
+ }
1804
+ };
1805
+
1806
+ // src/locks/RedisLock.ts
1807
+ import { randomUUID } from "crypto";
1808
+ var RedisLock = class {
1809
+ /**
1810
+ * Constructs a new RedisLock instance with the specified configuration.
1811
+ *
1812
+ * @param options - Configuration including Redis client and retry parameters.
1813
+ *
1814
+ * @example With custom retry strategy
1815
+ * ```typescript
1816
+ * const lock = new RedisLock({
1817
+ * client: redisClient,
1818
+ * keyPrefix: 'app:locks:',
1819
+ * retryCount: 10, // More retries for high-contention scenarios
1820
+ * retryDelay: 50 // Shorter delay for low-latency requirements
1821
+ * })
1822
+ * ```
1823
+ */
1824
+ constructor(options) {
1825
+ this.options = options;
1826
+ this.keyPrefix = options.keyPrefix || "sitemap:lock:";
1827
+ this.retryCount = options.retryCount ?? 0;
1828
+ this.retryDelay = options.retryDelay ?? 100;
1829
+ }
1830
+ /**
1831
+ * Unique identifier for this lock instance.
1832
+ *
1833
+ * Generated once during construction and used for all locks acquired by this instance.
1834
+ * Enables ownership validation: only the instance that acquired the lock can release it.
1835
+ *
1836
+ * **Security consideration:**
1837
+ * UUIDs are sufficiently random to prevent lock hijacking across instances.
1838
+ * However, they are stored in plain text in Redis (not encrypted).
1839
+ */
1840
+ lockId = randomUUID();
1841
+ /**
1842
+ * Redis key prefix for all locks acquired through this instance.
1843
+ *
1844
+ * Combined with resource name to form full Redis key (e.g., 'sitemap:lock:generation').
1845
+ * Allows namespace isolation and easier debugging in Redis CLI.
1846
+ */
1847
+ keyPrefix;
1848
+ /**
1849
+ * Maximum number of retry attempts when lock is held by another instance.
1850
+ *
1851
+ * Set to 0 for fail-fast behavior. Higher values increase acquisition success
1852
+ * rate but also increase latency under contention.
1853
+ */
1854
+ retryCount;
1855
+ /**
1856
+ * Delay in milliseconds between consecutive retry attempts.
1857
+ *
1858
+ * Should be tuned based on expected lock hold time. Typical values: 50-500ms.
1859
+ */
1860
+ retryDelay;
1861
+ /**
1862
+ * Attempts to acquire a distributed lock using Redis SET NX EX command.
1863
+ *
1864
+ * Uses atomic Redis operations to ensure only one instance across your entire
1865
+ * infrastructure can hold the lock at any given time. Implements retry logic
1866
+ * with exponential backoff for handling transient contention.
1867
+ *
1868
+ * **Algorithm:**
1869
+ * 1. Convert TTL from milliseconds to seconds (Redis requirement)
1870
+ * 2. Attempt Redis SET key lockId EX ttl NX (atomic operation)
1871
+ * 3. If successful (returns 'OK'), lock acquired
1872
+ * 4. If failed (returns null), retry up to retryCount times with retryDelay
1873
+ * 5. Return true if acquired, false if all attempts exhausted
1874
+ *
1875
+ * **Atomicity guarantee:**
1876
+ * The combination of NX (set if Not eXists) and EX (set EXpiration) in a single
1877
+ * Redis command ensures no race conditions. Either the lock is acquired or it isn't.
1878
+ *
1879
+ * **Error handling:**
1880
+ * Redis connection errors are caught, logged to console, and treated as acquisition
1881
+ * failure (returns false). This fail-safe behavior prevents exceptions from bubbling
1882
+ * up to application code.
1883
+ *
1884
+ * **Performance:**
1885
+ * - Single instance: O(1) Redis operation
1886
+ * - With retry: O(retryCount) worst case
1887
+ * - Network latency: ~1-5ms per attempt (depends on Redis location)
1888
+ *
1889
+ * @param resource - Unique identifier for the resource to lock.
1890
+ * Combined with keyPrefix to form Redis key.
1891
+ * @param ttl - Time-to-live in milliseconds. Lock auto-expires after this duration.
1892
+ * Recommended: 2-5x expected operation time to handle slowdowns.
1893
+ * Minimum: 1000ms (1 second) for practical use.
1894
+ * @returns Promise resolving to `true` if lock acquired, `false` if held by another instance
1895
+ * or Redis connection failed.
1896
+ *
1897
+ * @example Basic acquisition in distributed environment
1898
+ * ```typescript
1899
+ * const lock = new RedisLock({ client: redisClient })
1900
+ * const acquired = await lock.acquire('sitemap-generation', 60000)
1901
+ *
1902
+ * if (!acquired) {
1903
+ * // Another instance (e.g., different Kubernetes pod) holds the lock
1904
+ * console.log('Sitemap generation in progress on another instance')
1905
+ * return new Response('Service busy', {
1906
+ * status: 503,
1907
+ * headers: { 'Retry-After': '30' }
1908
+ * })
1909
+ * }
1910
+ *
1911
+ * try {
1912
+ * await generateSitemap()
1913
+ * } finally {
1914
+ * await lock.release('sitemap-generation')
1915
+ * }
1916
+ * ```
1917
+ *
1918
+ * @example With retry logic for transient contention
1919
+ * ```typescript
1920
+ * const lock = new RedisLock({
1921
+ * client: redisClient,
1922
+ * retryCount: 5,
1923
+ * retryDelay: 200
1924
+ * })
1925
+ *
1926
+ * // Will retry 5 times with 200ms delay between attempts
1927
+ * const acquired = await lock.acquire('data-import', 120000)
1928
+ * ```
1929
+ *
1930
+ * @example Setting appropriate TTL
1931
+ * ```typescript
1932
+ * // Fast operation: short TTL
1933
+ * await lock.acquire('cache-rebuild', 10000) // 10 seconds
1934
+ *
1935
+ * // Slow operation: longer TTL with buffer
1936
+ * await lock.acquire('full-sitemap', 300000) // 5 minutes
1937
+ *
1938
+ * // Very slow operation: generous TTL
1939
+ * await lock.acquire('data-migration', 1800000) // 30 minutes
1940
+ * ```
1941
+ */
1942
+ async acquire(resource, ttl) {
1943
+ const key = this.keyPrefix + resource;
1944
+ const ttlSeconds = Math.ceil(ttl / 1e3);
1945
+ let attempts = 0;
1946
+ while (attempts <= this.retryCount) {
1947
+ try {
1948
+ const result = await this.options.client.set(key, this.lockId, "EX", ttlSeconds, "NX");
1949
+ if (result === "OK") {
1950
+ return true;
1951
+ }
1952
+ } catch (error) {
1953
+ const err = error instanceof Error ? error : new Error(String(error));
1954
+ console.error(`[RedisLock] Failed to acquire lock for ${resource}:`, err.message);
1955
+ }
1956
+ attempts++;
1957
+ if (attempts <= this.retryCount) {
1958
+ await this.sleep(this.retryDelay);
1959
+ }
1960
+ }
1961
+ return false;
903
1962
  }
904
1963
  /**
905
- * 帶進度追蹤的生成
1964
+ * Releases the distributed lock using Lua script for atomic ownership validation.
1965
+ *
1966
+ * Uses a Lua script to atomically check if the current instance owns the lock
1967
+ * (by comparing lockId) and delete it if so. This prevents accidentally releasing
1968
+ * locks held by other instances, which could cause data corruption in distributed systems.
1969
+ *
1970
+ * **Lua script logic:**
1971
+ * ```lua
1972
+ * if redis.call("get", KEYS[1]) == ARGV[1] then
1973
+ * return redis.call("del", KEYS[1]) -- Delete only if owner matches
1974
+ * else
1975
+ * return 0 -- Not owner or lock expired, do nothing
1976
+ * end
1977
+ * ```
1978
+ *
1979
+ * **Why Lua scripts?**
1980
+ * - Atomicity: GET + comparison + DEL execute as one atomic operation
1981
+ * - Prevents race conditions: Lock cannot change between check and delete
1982
+ * - Server-side execution: No network round trips between steps
1983
+ *
1984
+ * **Error handling:**
1985
+ * Errors during release (e.g., Redis connection loss) are logged but do NOT throw.
1986
+ * This is intentional: if instance crashed or TTL expired, lock is already released.
1987
+ * Silent failure here prevents cascading errors in finally blocks.
1988
+ *
1989
+ * **Idempotency:**
1990
+ * Safe to call multiple times. Releasing an already-released or expired lock is a no-op.
1991
+ *
1992
+ * @param resource - The resource identifier to unlock. Must match the identifier
1993
+ * used in the corresponding `acquire()` call.
1994
+ *
1995
+ * @example Proper release pattern with try-finally
1996
+ * ```typescript
1997
+ * const acquired = await lock.acquire('sitemap-generation', 60000)
1998
+ * if (!acquired) return
1999
+ *
2000
+ * try {
2001
+ * await generateSitemap()
2002
+ * } finally {
2003
+ * // Always release, even if operation throws
2004
+ * await lock.release('sitemap-generation')
2005
+ * }
2006
+ * ```
2007
+ *
2008
+ * @example Handling operation failures
2009
+ * ```typescript
2010
+ * const acquired = await lock.acquire('data-processing', 120000)
2011
+ * if (!acquired) {
2012
+ * throw new Error('Could not acquire lock')
2013
+ * }
2014
+ *
2015
+ * try {
2016
+ * await processData()
2017
+ * } catch (error) {
2018
+ * console.error('Processing failed:', error)
2019
+ * // Lock still released in finally block
2020
+ * throw error
2021
+ * } finally {
2022
+ * await lock.release('data-processing')
2023
+ * }
2024
+ * ```
2025
+ *
2026
+ * @example Why ownership validation matters
2027
+ * ```typescript
2028
+ * // Instance A acquires lock with 10-second TTL
2029
+ * const lockA = new RedisLock({ client: redisClientA })
2030
+ * await lockA.acquire('task', 10000)
2031
+ *
2032
+ * // ... 11 seconds pass, lock auto-expires ...
2033
+ *
2034
+ * // Instance B acquires the now-expired lock
2035
+ * const lockB = new RedisLock({ client: redisClientB })
2036
+ * await lockB.acquire('task', 10000)
2037
+ *
2038
+ * // Instance A tries to release (after slowdown/GC pause)
2039
+ * await lockA.release('task')
2040
+ * // ✅ Lua script detects lockId mismatch, does NOT delete B's lock
2041
+ * // ❌ Without Lua: Would delete B's lock, causing data corruption
2042
+ * ```
906
2043
  */
907
- async generateWithProgress() {
908
- const { progressTracker, onProgress } = this.options;
909
- await this.generator.run();
910
- this.processedEntries = this.totalEntries;
911
- if (progressTracker) {
912
- await progressTracker.update(this.processedEntries, "processing");
913
- }
914
- if (onProgress) {
915
- onProgress({
916
- processed: this.processedEntries,
917
- total: this.totalEntries,
918
- percentage: this.totalEntries > 0 ? Math.round(this.processedEntries / this.totalEntries * 100) : 100
919
- });
2044
+ async release(resource) {
2045
+ const key = this.keyPrefix + resource;
2046
+ try {
2047
+ const script = `
2048
+ if redis.call("get", KEYS[1]) == ARGV[1] then
2049
+ return redis.call("del", KEYS[1])
2050
+ else
2051
+ return 0
2052
+ end
2053
+ `;
2054
+ await this.options.client.eval(script, 1, key, this.lockId);
2055
+ } catch (error) {
2056
+ const err = error instanceof Error ? error : new Error(String(error));
2057
+ console.error(`[RedisLock] Failed to release lock for ${resource}:`, err.message);
920
2058
  }
921
2059
  }
2060
+ /**
2061
+ * Internal utility for sleeping between retry attempts.
2062
+ *
2063
+ * @param ms - Duration to sleep in milliseconds
2064
+ */
2065
+ sleep(ms) {
2066
+ return new Promise((resolve) => setTimeout(resolve, ms));
2067
+ }
922
2068
  };
923
2069
 
924
2070
  // src/OrbitSitemap.ts
925
- import { randomUUID } from "crypto";
2071
+ import { randomUUID as randomUUID2 } from "crypto";
926
2072
 
927
2073
  // src/redirect/RedirectHandler.ts
928
2074
  var RedirectHandler = class {
@@ -931,7 +2077,10 @@ var RedirectHandler = class {
931
2077
  this.options = options;
932
2078
  }
933
2079
  /**
934
- * 處理 entries 中的轉址
2080
+ * Processes a list of sitemap entries and handles redirects according to the configured strategy.
2081
+ *
2082
+ * @param entries - The original list of sitemap entries.
2083
+ * @returns A promise resolving to the processed list of entries.
935
2084
  */
936
2085
  async processEntries(entries) {
937
2086
  const { manager, strategy, followChains, maxChainLength } = this.options;
@@ -962,7 +2111,7 @@ var RedirectHandler = class {
962
2111
  }
963
2112
  }
964
2113
  /**
965
- * 策略一:移除舊 URL,加入新 URL
2114
+ * Strategy 1: Remove old URL and add the new destination URL.
966
2115
  */
967
2116
  handleRemoveOldAddNew(entries, redirectMap) {
968
2117
  const processed = [];
@@ -987,7 +2136,7 @@ var RedirectHandler = class {
987
2136
  return processed;
988
2137
  }
989
2138
  /**
990
- * 策略二:保留關聯,使用 canonical link
2139
+ * Strategy 2: Keep the original URL but mark the destination as canonical.
991
2140
  */
992
2141
  handleKeepRelation(entries, redirectMap) {
993
2142
  const processed = [];
@@ -1010,7 +2159,7 @@ var RedirectHandler = class {
1010
2159
  return processed;
1011
2160
  }
1012
2161
  /**
1013
- * 策略三:僅更新 URL
2162
+ * Strategy 3: Silently update the URL to the destination.
1014
2163
  */
1015
2164
  handleUpdateUrl(entries, redirectMap) {
1016
2165
  return entries.map((entry) => {
@@ -1030,7 +2179,7 @@ var RedirectHandler = class {
1030
2179
  });
1031
2180
  }
1032
2181
  /**
1033
- * 策略四:雙重標記
2182
+ * Strategy 4: Include both the original and destination URLs.
1034
2183
  */
1035
2184
  handleDualMark(entries, redirectMap) {
1036
2185
  const processed = [];
@@ -1072,15 +2221,82 @@ var MemorySitemapStorage = class {
1072
2221
  this.baseUrl = baseUrl;
1073
2222
  }
1074
2223
  files = /* @__PURE__ */ new Map();
2224
+ /**
2225
+ * Writes sitemap content to memory.
2226
+ *
2227
+ * @param filename - The name of the file to store.
2228
+ * @param content - The XML or JSON content.
2229
+ */
1075
2230
  async write(filename, content) {
1076
2231
  this.files.set(filename, content);
1077
2232
  }
2233
+ /**
2234
+ * 使用串流方式寫入 sitemap 至記憶體,可選擇性啟用 gzip 壓縮。
2235
+ * 記憶體儲存會收集串流為完整字串。
2236
+ *
2237
+ * @param filename - 檔案名稱
2238
+ * @param stream - XML 內容的 AsyncIterable
2239
+ * @param options - 寫入選項(如壓縮)
2240
+ *
2241
+ * @since 3.1.0
2242
+ */
2243
+ async writeStream(filename, stream, options) {
2244
+ const chunks = [];
2245
+ for await (const chunk of stream) {
2246
+ chunks.push(chunk);
2247
+ }
2248
+ const content = chunks.join("");
2249
+ const key = options?.compress ? toGzipFilename(filename) : filename;
2250
+ if (options?.compress) {
2251
+ const compressed = await compressToBuffer(
2252
+ (async function* () {
2253
+ yield content;
2254
+ })()
2255
+ );
2256
+ this.files.set(key, compressed.toString("base64"));
2257
+ } else {
2258
+ this.files.set(key, content);
2259
+ }
2260
+ }
2261
+ /**
2262
+ * Reads sitemap content from memory.
2263
+ *
2264
+ * @param filename - The name of the file to read.
2265
+ * @returns A promise resolving to the file content as a string, or null if not found.
2266
+ */
1078
2267
  async read(filename) {
1079
2268
  return this.files.get(filename) || null;
1080
2269
  }
2270
+ /**
2271
+ * Returns a readable stream for a sitemap file in memory.
2272
+ *
2273
+ * @param filename - The name of the file to stream.
2274
+ * @returns A promise resolving to an async iterable of file chunks, or null if not found.
2275
+ */
2276
+ async readStream(filename) {
2277
+ const content = this.files.get(filename);
2278
+ if (content === void 0) {
2279
+ return null;
2280
+ }
2281
+ return (async function* () {
2282
+ yield content;
2283
+ })();
2284
+ }
2285
+ /**
2286
+ * Checks if a sitemap file exists in memory.
2287
+ *
2288
+ * @param filename - The name of the file to check.
2289
+ * @returns A promise resolving to true if the file exists, false otherwise.
2290
+ */
1081
2291
  async exists(filename) {
1082
2292
  return this.files.has(filename);
1083
2293
  }
2294
+ /**
2295
+ * Returns the full public URL for a sitemap file.
2296
+ *
2297
+ * @param filename - The name of the sitemap file.
2298
+ * @returns The public URL as a string.
2299
+ */
1084
2300
  getUrl(filename) {
1085
2301
  const base = this.baseUrl.endsWith("/") ? this.baseUrl.slice(0, -1) : this.baseUrl;
1086
2302
  const file = filename.startsWith("/") ? filename.slice(1) : filename;
@@ -1136,7 +2352,7 @@ var OrbitSitemap = class _OrbitSitemap {
1136
2352
  });
1137
2353
  }
1138
2354
  /**
1139
- * Install the sitemap module into PlanetCore.
2355
+ * Installs the sitemap module into PlanetCore.
1140
2356
  *
1141
2357
  * @param core - The PlanetCore instance.
1142
2358
  */
@@ -1147,6 +2363,9 @@ var OrbitSitemap = class _OrbitSitemap {
1147
2363
  core.logger.info("[OrbitSitemap] Static mode configured. Use generate() to build sitemaps.");
1148
2364
  }
1149
2365
  }
2366
+ /**
2367
+ * Internal method to set up dynamic sitemap routes.
2368
+ */
1150
2369
  installDynamic(core) {
1151
2370
  const opts = this.options;
1152
2371
  const storage = opts.storage ?? new MemorySitemapStorage(opts.baseUrl);
@@ -1204,7 +2423,7 @@ var OrbitSitemap = class _OrbitSitemap {
1204
2423
  core.router.get(shardRoute, handler);
1205
2424
  }
1206
2425
  /**
1207
- * Generate the sitemap (static mode only).
2426
+ * Generates the sitemap (static mode only).
1208
2427
  *
1209
2428
  * @returns A promise that resolves when generation is complete.
1210
2429
  * @throws {Error} If called in dynamic mode.
@@ -1216,7 +2435,7 @@ var OrbitSitemap = class _OrbitSitemap {
1216
2435
  const opts = this.options;
1217
2436
  let storage = opts.storage;
1218
2437
  if (!storage) {
1219
- const { DiskSitemapStorage: DiskSitemapStorage2 } = await import("./DiskSitemapStorage-7ZZMGC4K.js");
2438
+ const { DiskSitemapStorage: DiskSitemapStorage2 } = await import("./DiskSitemapStorage-VLN5I24C.js");
1220
2439
  storage = new DiskSitemapStorage2(opts.outDir, opts.baseUrl);
1221
2440
  }
1222
2441
  let providers = opts.providers;
@@ -1246,7 +2465,7 @@ var OrbitSitemap = class _OrbitSitemap {
1246
2465
  console.log(`[OrbitSitemap] Generated sitemap in ${opts.outDir}`);
1247
2466
  }
1248
2467
  /**
1249
- * Generate incremental sitemap updates (static mode only).
2468
+ * Generates incremental sitemap updates (static mode only).
1250
2469
  *
1251
2470
  * @param since - Only include items modified since this date.
1252
2471
  * @returns A promise that resolves when incremental generation is complete.
@@ -1262,7 +2481,7 @@ var OrbitSitemap = class _OrbitSitemap {
1262
2481
  }
1263
2482
  let storage = opts.storage;
1264
2483
  if (!storage) {
1265
- const { DiskSitemapStorage: DiskSitemapStorage2 } = await import("./DiskSitemapStorage-7ZZMGC4K.js");
2484
+ const { DiskSitemapStorage: DiskSitemapStorage2 } = await import("./DiskSitemapStorage-VLN5I24C.js");
1266
2485
  storage = new DiskSitemapStorage2(opts.outDir, opts.baseUrl);
1267
2486
  }
1268
2487
  const incrementalGenerator = new IncrementalGenerator({
@@ -1277,7 +2496,7 @@ var OrbitSitemap = class _OrbitSitemap {
1277
2496
  console.log(`[OrbitSitemap] Generated incremental sitemap in ${opts.outDir}`);
1278
2497
  }
1279
2498
  /**
1280
- * Generate sitemap asynchronously in the background (static mode only).
2499
+ * Generates sitemap asynchronously in the background (static mode only).
1281
2500
  *
1282
2501
  * @param options - Options for the async generation job.
1283
2502
  * @returns A promise resolving to the job ID.
@@ -1288,10 +2507,10 @@ var OrbitSitemap = class _OrbitSitemap {
1288
2507
  throw new Error("generateAsync() can only be called in static mode");
1289
2508
  }
1290
2509
  const opts = this.options;
1291
- const jobId = randomUUID();
2510
+ const jobId = randomUUID2();
1292
2511
  let storage = opts.storage;
1293
2512
  if (!storage) {
1294
- const { DiskSitemapStorage: DiskSitemapStorage2 } = await import("./DiskSitemapStorage-7ZZMGC4K.js");
2513
+ const { DiskSitemapStorage: DiskSitemapStorage2 } = await import("./DiskSitemapStorage-VLN5I24C.js");
1295
2514
  storage = new DiskSitemapStorage2(opts.outDir, opts.baseUrl);
1296
2515
  }
1297
2516
  let providers = opts.providers;
@@ -1338,7 +2557,7 @@ var OrbitSitemap = class _OrbitSitemap {
1338
2557
  return jobId;
1339
2558
  }
1340
2559
  /**
1341
- * Install API endpoints for triggering and monitoring sitemap generation.
2560
+ * Installs API endpoints for triggering and monitoring sitemap generation.
1342
2561
  *
1343
2562
  * @param core - The PlanetCore instance.
1344
2563
  * @param basePath - The base path for the API endpoints (default: '/admin/sitemap').
@@ -1411,9 +2630,12 @@ var RouteScanner = class {
1411
2630
  };
1412
2631
  }
1413
2632
  /**
1414
- * Scan the router and return discovered entries.
2633
+ * Scans the router and returns discovered static GET routes as sitemap entries.
1415
2634
  *
1416
- * @returns An array of sitemap entries.
2635
+ * This method iterates through all registered routes in the Gravito router,
2636
+ * applying inclusion/exclusion filters and defaulting metadata for matching routes.
2637
+ *
2638
+ * @returns An array of `SitemapEntry` objects.
1417
2639
  */
1418
2640
  getEntries() {
1419
2641
  const entries = [];
@@ -1475,7 +2697,10 @@ var RedirectDetector = class {
1475
2697
  this.options = options;
1476
2698
  }
1477
2699
  /**
1478
- * 偵測單一 URL 的轉址
2700
+ * Detects redirects for a single URL using multiple strategies.
2701
+ *
2702
+ * @param url - The URL path to probe for redirects.
2703
+ * @returns A promise resolving to a `RedirectRule` if a redirect is found, or null.
1479
2704
  */
1480
2705
  async detect(url) {
1481
2706
  if (this.options.autoDetect?.cache) {
@@ -1507,7 +2732,10 @@ var RedirectDetector = class {
1507
2732
  return null;
1508
2733
  }
1509
2734
  /**
1510
- * 批次偵測轉址
2735
+ * Batch detects redirects for multiple URLs with concurrency control.
2736
+ *
2737
+ * @param urls - An array of URL paths to probe.
2738
+ * @returns A promise resolving to a Map of URLs to their respective `RedirectRule` or null.
1511
2739
  */
1512
2740
  async detectBatch(urls) {
1513
2741
  const results = /* @__PURE__ */ new Map();
@@ -1527,7 +2755,7 @@ var RedirectDetector = class {
1527
2755
  return results;
1528
2756
  }
1529
2757
  /**
1530
- * 從資料庫偵測
2758
+ * Detects a redirect from the configured database table.
1531
2759
  */
1532
2760
  async detectFromDatabase(url) {
1533
2761
  const { database } = this.options;
@@ -1545,14 +2773,14 @@ var RedirectDetector = class {
1545
2773
  return {
1546
2774
  from: row[columns.from],
1547
2775
  to: row[columns.to],
1548
- type: parseInt(row[columns.type], 10)
2776
+ type: Number.parseInt(row[columns.type], 10)
1549
2777
  };
1550
2778
  } catch {
1551
2779
  return null;
1552
2780
  }
1553
2781
  }
1554
2782
  /**
1555
- * 從設定檔偵測
2783
+ * Detects a redirect from a static JSON configuration file.
1556
2784
  */
1557
2785
  async detectFromConfig(url) {
1558
2786
  const { config } = this.options;
@@ -1570,7 +2798,7 @@ var RedirectDetector = class {
1570
2798
  }
1571
2799
  }
1572
2800
  /**
1573
- * 自動偵測(透過 HTTP 請求)
2801
+ * Auto-detects a redirect by sending an HTTP HEAD request.
1574
2802
  */
1575
2803
  async detectAuto(url) {
1576
2804
  const { autoDetect, baseUrl } = this.options;
@@ -1587,7 +2815,7 @@ var RedirectDetector = class {
1587
2815
  method: "HEAD",
1588
2816
  signal: controller.signal,
1589
2817
  redirect: "manual"
1590
- // 手動處理轉址
2818
+ // Handle redirects manually
1591
2819
  });
1592
2820
  clearTimeout(timeoutId);
1593
2821
  if (response.status === 301 || response.status === 302) {
@@ -1611,7 +2839,7 @@ var RedirectDetector = class {
1611
2839
  return null;
1612
2840
  }
1613
2841
  /**
1614
- * 快取結果
2842
+ * Caches the detection result for a URL.
1615
2843
  */
1616
2844
  cacheResult(url, rule) {
1617
2845
  if (!this.options.autoDetect?.cache) {
@@ -1632,6 +2860,11 @@ var MemoryRedirectManager = class {
1632
2860
  constructor(options = {}) {
1633
2861
  this.maxRules = options.maxRules || 1e5;
1634
2862
  }
2863
+ /**
2864
+ * Registers a single redirect rule in memory.
2865
+ *
2866
+ * @param redirect - The redirect rule to add.
2867
+ */
1635
2868
  async register(redirect) {
1636
2869
  this.rules.set(redirect.from, redirect);
1637
2870
  if (this.rules.size > this.maxRules) {
@@ -1641,17 +2874,41 @@ var MemoryRedirectManager = class {
1641
2874
  }
1642
2875
  }
1643
2876
  }
2877
+ /**
2878
+ * Registers multiple redirect rules in memory.
2879
+ *
2880
+ * @param redirects - An array of redirect rules.
2881
+ */
1644
2882
  async registerBatch(redirects) {
1645
2883
  for (const redirect of redirects) {
1646
2884
  await this.register(redirect);
1647
2885
  }
1648
2886
  }
2887
+ /**
2888
+ * Retrieves a specific redirect rule by its source path from memory.
2889
+ *
2890
+ * @param from - The source path.
2891
+ * @returns A promise resolving to the redirect rule, or null if not found.
2892
+ */
1649
2893
  async get(from) {
1650
2894
  return this.rules.get(from) || null;
1651
2895
  }
2896
+ /**
2897
+ * Retrieves all registered redirect rules from memory.
2898
+ *
2899
+ * @returns A promise resolving to an array of all redirect rules.
2900
+ */
1652
2901
  async getAll() {
1653
2902
  return Array.from(this.rules.values());
1654
2903
  }
2904
+ /**
2905
+ * Resolves a URL to its final destination through the redirect table.
2906
+ *
2907
+ * @param url - The URL to resolve.
2908
+ * @param followChains - Whether to recursively resolve chained redirects.
2909
+ * @param maxChainLength - Maximum depth for chain resolution.
2910
+ * @returns A promise resolving to the final destination URL.
2911
+ */
1655
2912
  async resolve(url, followChains = false, maxChainLength = 5) {
1656
2913
  let current = url;
1657
2914
  let chainLength = 0;
@@ -1684,6 +2941,11 @@ var RedisRedirectManager = class {
1684
2941
  getListKey() {
1685
2942
  return `${this.keyPrefix}list`;
1686
2943
  }
2944
+ /**
2945
+ * Registers a single redirect rule in Redis.
2946
+ *
2947
+ * @param redirect - The redirect rule to add.
2948
+ */
1687
2949
  async register(redirect) {
1688
2950
  const key = this.getKey(redirect.from);
1689
2951
  const listKey = this.getListKey();
@@ -1695,11 +2957,22 @@ var RedisRedirectManager = class {
1695
2957
  }
1696
2958
  await this.client.sadd(listKey, redirect.from);
1697
2959
  }
2960
+ /**
2961
+ * Registers multiple redirect rules in Redis.
2962
+ *
2963
+ * @param redirects - An array of redirect rules.
2964
+ */
1698
2965
  async registerBatch(redirects) {
1699
2966
  for (const redirect of redirects) {
1700
2967
  await this.register(redirect);
1701
2968
  }
1702
2969
  }
2970
+ /**
2971
+ * Retrieves a specific redirect rule by its source path from Redis.
2972
+ *
2973
+ * @param from - The source path.
2974
+ * @returns A promise resolving to the redirect rule, or null if not found.
2975
+ */
1703
2976
  async get(from) {
1704
2977
  try {
1705
2978
  const key = this.getKey(from);
@@ -1716,6 +2989,11 @@ var RedisRedirectManager = class {
1716
2989
  return null;
1717
2990
  }
1718
2991
  }
2992
+ /**
2993
+ * Retrieves all registered redirect rules from Redis.
2994
+ *
2995
+ * @returns A promise resolving to an array of all redirect rules.
2996
+ */
1719
2997
  async getAll() {
1720
2998
  try {
1721
2999
  const listKey = this.getListKey();
@@ -1732,6 +3010,14 @@ var RedisRedirectManager = class {
1732
3010
  return [];
1733
3011
  }
1734
3012
  }
3013
+ /**
3014
+ * Resolves a URL to its final destination through the Redis redirect table.
3015
+ *
3016
+ * @param url - The URL to resolve.
3017
+ * @param followChains - Whether to recursively resolve chained redirects.
3018
+ * @param maxChainLength - Maximum depth for chain resolution.
3019
+ * @returns A promise resolving to the final destination URL.
3020
+ */
1735
3021
  async resolve(url, followChains = false, maxChainLength = 5) {
1736
3022
  let current = url;
1737
3023
  let chainLength = 0;
@@ -1751,6 +3037,8 @@ var RedisRedirectManager = class {
1751
3037
  };
1752
3038
 
1753
3039
  // src/storage/GCPSitemapStorage.ts
3040
+ import { Readable } from "stream";
3041
+ import { pipeline } from "stream/promises";
1754
3042
  var GCPSitemapStorage = class {
1755
3043
  bucket;
1756
3044
  prefix;
@@ -1789,6 +3077,12 @@ var GCPSitemapStorage = class {
1789
3077
  const cleanPrefix = this.prefix.endsWith("/") ? this.prefix.slice(0, -1) : this.prefix;
1790
3078
  return cleanPrefix ? `${cleanPrefix}/${filename}` : filename;
1791
3079
  }
3080
+ /**
3081
+ * Writes sitemap content to a Google Cloud Storage object.
3082
+ *
3083
+ * @param filename - The name of the file to write.
3084
+ * @param content - The XML or JSON content.
3085
+ */
1792
3086
  async write(filename, content) {
1793
3087
  const { bucket } = await this.getStorageClient();
1794
3088
  const key = this.getKey(filename);
@@ -1800,6 +3094,41 @@ var GCPSitemapStorage = class {
1800
3094
  }
1801
3095
  });
1802
3096
  }
3097
+ /**
3098
+ * 使用串流方式寫入 sitemap 至 GCP Cloud Storage,可選擇性啟用 gzip 壓縮。
3099
+ *
3100
+ * @param filename - 檔案名稱
3101
+ * @param stream - XML 內容的 AsyncIterable
3102
+ * @param options - 寫入選項(如壓縮、content type)
3103
+ *
3104
+ * @since 3.1.0
3105
+ */
3106
+ async writeStream(filename, stream, options) {
3107
+ const { bucket } = await this.getStorageClient();
3108
+ const key = this.getKey(options?.compress ? toGzipFilename(filename) : filename);
3109
+ const file = bucket.file(key);
3110
+ const writeStream = file.createWriteStream({
3111
+ resumable: false,
3112
+ contentType: "application/xml",
3113
+ metadata: {
3114
+ contentEncoding: options?.compress ? "gzip" : void 0,
3115
+ cacheControl: "public, max-age=3600"
3116
+ }
3117
+ });
3118
+ const readable = Readable.from(stream, { encoding: "utf-8" });
3119
+ if (options?.compress) {
3120
+ const gzip = createCompressionStream();
3121
+ await pipeline(readable, gzip, writeStream);
3122
+ } else {
3123
+ await pipeline(readable, writeStream);
3124
+ }
3125
+ }
3126
+ /**
3127
+ * Reads sitemap content from a Google Cloud Storage object.
3128
+ *
3129
+ * @param filename - The name of the file to read.
3130
+ * @returns A promise resolving to the file content as a string, or null if not found.
3131
+ */
1803
3132
  async read(filename) {
1804
3133
  try {
1805
3134
  const { bucket } = await this.getStorageClient();
@@ -1818,6 +3147,42 @@ var GCPSitemapStorage = class {
1818
3147
  throw error;
1819
3148
  }
1820
3149
  }
3150
+ /**
3151
+ * Returns a readable stream for a Google Cloud Storage object.
3152
+ *
3153
+ * @param filename - The name of the file to stream.
3154
+ * @returns A promise resolving to an async iterable of file chunks, or null if not found.
3155
+ */
3156
+ async readStream(filename) {
3157
+ try {
3158
+ const { bucket } = await this.getStorageClient();
3159
+ const key = this.getKey(filename);
3160
+ const file = bucket.file(key);
3161
+ const [exists] = await file.exists();
3162
+ if (!exists) {
3163
+ return null;
3164
+ }
3165
+ const stream = file.createReadStream();
3166
+ return (async function* () {
3167
+ const decoder = new TextDecoder();
3168
+ for await (const chunk of stream) {
3169
+ yield decoder.decode(chunk, { stream: true });
3170
+ }
3171
+ yield decoder.decode();
3172
+ })();
3173
+ } catch (error) {
3174
+ if (error.code === 404) {
3175
+ return null;
3176
+ }
3177
+ throw error;
3178
+ }
3179
+ }
3180
+ /**
3181
+ * Checks if a Google Cloud Storage object exists.
3182
+ *
3183
+ * @param filename - The name of the file to check.
3184
+ * @returns A promise resolving to true if the file exists, false otherwise.
3185
+ */
1821
3186
  async exists(filename) {
1822
3187
  try {
1823
3188
  const { bucket } = await this.getStorageClient();
@@ -1829,18 +3194,30 @@ var GCPSitemapStorage = class {
1829
3194
  return false;
1830
3195
  }
1831
3196
  }
3197
+ /**
3198
+ * Returns the full public URL for a Google Cloud Storage object.
3199
+ *
3200
+ * @param filename - The name of the sitemap file.
3201
+ * @returns The public URL as a string.
3202
+ */
1832
3203
  getUrl(filename) {
1833
3204
  const key = this.getKey(filename);
1834
3205
  const base = this.baseUrl.endsWith("/") ? this.baseUrl.slice(0, -1) : this.baseUrl;
1835
3206
  return `${base}/${key}`;
1836
3207
  }
1837
- // 影子處理方法
3208
+ /**
3209
+ * Writes content to a shadow (staged) location in Google Cloud Storage.
3210
+ *
3211
+ * @param filename - The name of the file to write.
3212
+ * @param content - The XML or JSON content.
3213
+ * @param shadowId - Optional unique session identifier.
3214
+ */
1838
3215
  async writeShadow(filename, content, shadowId) {
1839
3216
  if (!this.shadowEnabled) {
1840
3217
  return this.write(filename, content);
1841
3218
  }
1842
3219
  const { bucket } = await this.getStorageClient();
1843
- const id = shadowId || `shadow-${Date.now()}-${Math.random().toString(36).substring(7)}`;
3220
+ const id = shadowId || `shadow-${Date.now()}-${crypto.randomUUID()}`;
1844
3221
  const shadowKey = this.getKey(`${filename}.shadow.${id}`);
1845
3222
  const file = bucket.file(shadowKey);
1846
3223
  await file.save(content, {
@@ -1850,6 +3227,11 @@ var GCPSitemapStorage = class {
1850
3227
  }
1851
3228
  });
1852
3229
  }
3230
+ /**
3231
+ * Commits all staged shadow objects in a session to production in Google Cloud Storage.
3232
+ *
3233
+ * @param shadowId - The identifier of the session to commit.
3234
+ */
1853
3235
  async commitShadow(shadowId) {
1854
3236
  if (!this.shadowEnabled) {
1855
3237
  return;
@@ -1876,6 +3258,12 @@ var GCPSitemapStorage = class {
1876
3258
  }
1877
3259
  }
1878
3260
  }
3261
+ /**
3262
+ * Lists all archived versions of a specific sitemap in Google Cloud Storage.
3263
+ *
3264
+ * @param filename - The sitemap filename.
3265
+ * @returns A promise resolving to an array of version identifiers.
3266
+ */
1879
3267
  async listVersions(filename) {
1880
3268
  if (this.shadowMode !== "versioned") {
1881
3269
  return [];
@@ -1897,6 +3285,12 @@ var GCPSitemapStorage = class {
1897
3285
  return [];
1898
3286
  }
1899
3287
  }
3288
+ /**
3289
+ * Reverts a sitemap to a previously archived version in Google Cloud Storage.
3290
+ *
3291
+ * @param filename - The sitemap filename.
3292
+ * @param version - The version identifier to switch to.
3293
+ */
1900
3294
  async switchVersion(filename, version) {
1901
3295
  if (this.shadowMode !== "versioned") {
1902
3296
  throw new Error("Version switching is only available in versioned mode");
@@ -1916,22 +3310,51 @@ var GCPSitemapStorage = class {
1916
3310
  // src/storage/MemoryProgressStorage.ts
1917
3311
  var MemoryProgressStorage = class {
1918
3312
  storage = /* @__PURE__ */ new Map();
3313
+ /**
3314
+ * Retrieves the progress of a specific generation job from memory.
3315
+ *
3316
+ * @param jobId - Unique identifier for the job.
3317
+ * @returns A promise resolving to the `SitemapProgress` object, or null if not found.
3318
+ */
1919
3319
  async get(jobId) {
1920
3320
  const progress = this.storage.get(jobId);
1921
3321
  return progress ? { ...progress } : null;
1922
3322
  }
3323
+ /**
3324
+ * Initializes or overwrites a progress record in memory.
3325
+ *
3326
+ * @param jobId - Unique identifier for the job.
3327
+ * @param progress - The initial or current state of the job progress.
3328
+ */
1923
3329
  async set(jobId, progress) {
1924
3330
  this.storage.set(jobId, { ...progress });
1925
3331
  }
3332
+ /**
3333
+ * Updates specific fields of an existing progress record in memory.
3334
+ *
3335
+ * @param jobId - Unique identifier for the job.
3336
+ * @param updates - Object containing the fields to update.
3337
+ */
1926
3338
  async update(jobId, updates) {
1927
3339
  const existing = this.storage.get(jobId);
1928
3340
  if (existing) {
1929
3341
  this.storage.set(jobId, { ...existing, ...updates });
1930
3342
  }
1931
3343
  }
3344
+ /**
3345
+ * Deletes a progress record from memory.
3346
+ *
3347
+ * @param jobId - Unique identifier for the job to remove.
3348
+ */
1932
3349
  async delete(jobId) {
1933
3350
  this.storage.delete(jobId);
1934
3351
  }
3352
+ /**
3353
+ * Lists the most recent sitemap generation jobs from memory.
3354
+ *
3355
+ * @param limit - Maximum number of records to return.
3356
+ * @returns A promise resolving to an array of `SitemapProgress` objects, sorted by start time.
3357
+ */
1935
3358
  async list(limit) {
1936
3359
  const all = Array.from(this.storage.values());
1937
3360
  const sorted = all.sort((a, b) => {
@@ -1959,6 +3382,12 @@ var RedisProgressStorage = class {
1959
3382
  getListKey() {
1960
3383
  return `${this.keyPrefix}list`;
1961
3384
  }
3385
+ /**
3386
+ * Retrieves the progress of a specific generation job from Redis.
3387
+ *
3388
+ * @param jobId - Unique identifier for the job.
3389
+ * @returns A promise resolving to the `SitemapProgress` object, or null if not found.
3390
+ */
1962
3391
  async get(jobId) {
1963
3392
  try {
1964
3393
  const key = this.getKey(jobId);
@@ -1978,6 +3407,12 @@ var RedisProgressStorage = class {
1978
3407
  return null;
1979
3408
  }
1980
3409
  }
3410
+ /**
3411
+ * Initializes or overwrites a progress record in Redis.
3412
+ *
3413
+ * @param jobId - Unique identifier for the job.
3414
+ * @param progress - The initial or current state of the job progress.
3415
+ */
1981
3416
  async set(jobId, progress) {
1982
3417
  const key = this.getKey(jobId);
1983
3418
  const listKey = this.getListKey();
@@ -1986,18 +3421,35 @@ var RedisProgressStorage = class {
1986
3421
  await this.client.zadd(listKey, Date.now(), jobId);
1987
3422
  await this.client.expire(listKey, this.ttl);
1988
3423
  }
3424
+ /**
3425
+ * Updates specific fields of an existing progress record in Redis.
3426
+ *
3427
+ * @param jobId - Unique identifier for the job.
3428
+ * @param updates - Object containing the fields to update.
3429
+ */
1989
3430
  async update(jobId, updates) {
1990
3431
  const existing = await this.get(jobId);
1991
3432
  if (existing) {
1992
3433
  await this.set(jobId, { ...existing, ...updates });
1993
3434
  }
1994
3435
  }
3436
+ /**
3437
+ * Deletes a progress record from Redis.
3438
+ *
3439
+ * @param jobId - Unique identifier for the job to remove.
3440
+ */
1995
3441
  async delete(jobId) {
1996
3442
  const key = this.getKey(jobId);
1997
3443
  const listKey = this.getListKey();
1998
3444
  await this.client.del(key);
1999
3445
  await this.client.zrem(listKey, jobId);
2000
3446
  }
3447
+ /**
3448
+ * Lists the most recent sitemap generation jobs from Redis.
3449
+ *
3450
+ * @param limit - Maximum number of records to return.
3451
+ * @returns A promise resolving to an array of `SitemapProgress` objects, sorted by start time.
3452
+ */
2001
3453
  async list(limit) {
2002
3454
  try {
2003
3455
  const listKey = this.getListKey();
@@ -2074,6 +3526,12 @@ var S3SitemapStorage = class {
2074
3526
  const cleanPrefix = this.prefix.endsWith("/") ? this.prefix.slice(0, -1) : this.prefix;
2075
3527
  return cleanPrefix ? `${cleanPrefix}/${filename}` : filename;
2076
3528
  }
3529
+ /**
3530
+ * Writes sitemap content to an S3 object.
3531
+ *
3532
+ * @param filename - The name of the file to write.
3533
+ * @param content - The XML or JSON content.
3534
+ */
2077
3535
  async write(filename, content) {
2078
3536
  const s3 = await this.getS3Client();
2079
3537
  const key = this.getKey(filename);
@@ -2086,6 +3544,48 @@ var S3SitemapStorage = class {
2086
3544
  })
2087
3545
  );
2088
3546
  }
3547
+ /**
3548
+ * 使用串流方式寫入 sitemap 至 S3,可選擇性啟用 gzip 壓縮。
3549
+ * S3 需要知道 Content-Length,因此會先收集串流為 Buffer。
3550
+ *
3551
+ * @param filename - 檔案名稱
3552
+ * @param stream - XML 內容的 AsyncIterable
3553
+ * @param options - 寫入選項(如壓縮、content type)
3554
+ *
3555
+ * @since 3.1.0
3556
+ */
3557
+ async writeStream(filename, stream, options) {
3558
+ const s3 = await this.getS3Client();
3559
+ const key = this.getKey(options?.compress ? toGzipFilename(filename) : filename);
3560
+ let body;
3561
+ const contentType = options?.contentType || "application/xml";
3562
+ let contentEncoding;
3563
+ if (options?.compress) {
3564
+ body = await compressToBuffer(stream);
3565
+ contentEncoding = "gzip";
3566
+ } else {
3567
+ const chunks = [];
3568
+ for await (const chunk of stream) {
3569
+ chunks.push(chunk);
3570
+ }
3571
+ body = chunks.join("");
3572
+ }
3573
+ await s3.client.send(
3574
+ new s3.PutObjectCommand({
3575
+ Bucket: this.bucket,
3576
+ Key: key,
3577
+ Body: body,
3578
+ ContentType: contentType,
3579
+ ContentEncoding: contentEncoding
3580
+ })
3581
+ );
3582
+ }
3583
+ /**
3584
+ * Reads sitemap content from an S3 object.
3585
+ *
3586
+ * @param filename - The name of the file to read.
3587
+ * @returns A promise resolving to the file content as a string, or null if not found.
3588
+ */
2089
3589
  async read(filename) {
2090
3590
  try {
2091
3591
  const s3 = await this.getS3Client();
@@ -2112,6 +3612,46 @@ var S3SitemapStorage = class {
2112
3612
  throw error;
2113
3613
  }
2114
3614
  }
3615
+ /**
3616
+ * Returns a readable stream for an S3 object.
3617
+ *
3618
+ * @param filename - The name of the file to stream.
3619
+ * @returns A promise resolving to an async iterable of file chunks, or null if not found.
3620
+ */
3621
+ async readStream(filename) {
3622
+ try {
3623
+ const s3 = await this.getS3Client();
3624
+ const key = this.getKey(filename);
3625
+ const response = await s3.client.send(
3626
+ new s3.GetObjectCommand({
3627
+ Bucket: this.bucket,
3628
+ Key: key
3629
+ })
3630
+ );
3631
+ if (!response.Body) {
3632
+ return null;
3633
+ }
3634
+ const body = response.Body;
3635
+ return (async function* () {
3636
+ const decoder = new TextDecoder();
3637
+ for await (const chunk of body) {
3638
+ yield decoder.decode(chunk, { stream: true });
3639
+ }
3640
+ yield decoder.decode();
3641
+ })();
3642
+ } catch (error) {
3643
+ if (error.name === "NoSuchKey" || error.$metadata?.httpStatusCode === 404) {
3644
+ return null;
3645
+ }
3646
+ throw error;
3647
+ }
3648
+ }
3649
+ /**
3650
+ * Checks if an S3 object exists.
3651
+ *
3652
+ * @param filename - The name of the file to check.
3653
+ * @returns A promise resolving to true if the file exists, false otherwise.
3654
+ */
2115
3655
  async exists(filename) {
2116
3656
  try {
2117
3657
  const s3 = await this.getS3Client();
@@ -2130,18 +3670,30 @@ var S3SitemapStorage = class {
2130
3670
  throw error;
2131
3671
  }
2132
3672
  }
3673
+ /**
3674
+ * Returns the full public URL for an S3 object.
3675
+ *
3676
+ * @param filename - The name of the sitemap file.
3677
+ * @returns The public URL as a string.
3678
+ */
2133
3679
  getUrl(filename) {
2134
3680
  const key = this.getKey(filename);
2135
3681
  const base = this.baseUrl.endsWith("/") ? this.baseUrl.slice(0, -1) : this.baseUrl;
2136
3682
  return `${base}/${key}`;
2137
3683
  }
2138
- // 影子處理方法
3684
+ /**
3685
+ * Writes content to a shadow (staged) location in S3.
3686
+ *
3687
+ * @param filename - The name of the file to write.
3688
+ * @param content - The XML or JSON content.
3689
+ * @param shadowId - Optional unique session identifier.
3690
+ */
2139
3691
  async writeShadow(filename, content, shadowId) {
2140
3692
  if (!this.shadowEnabled) {
2141
3693
  return this.write(filename, content);
2142
3694
  }
2143
3695
  const s3 = await this.getS3Client();
2144
- const id = shadowId || `shadow-${Date.now()}-${Math.random().toString(36).substring(7)}`;
3696
+ const id = shadowId || `shadow-${Date.now()}-${crypto.randomUUID()}`;
2145
3697
  const shadowKey = this.getKey(`${filename}.shadow.${id}`);
2146
3698
  await s3.client.send(
2147
3699
  new s3.PutObjectCommand({
@@ -2152,6 +3704,11 @@ var S3SitemapStorage = class {
2152
3704
  })
2153
3705
  );
2154
3706
  }
3707
+ /**
3708
+ * Commits all staged shadow objects in a session to production.
3709
+ *
3710
+ * @param shadowId - The identifier of the session to commit.
3711
+ */
2155
3712
  async commitShadow(shadowId) {
2156
3713
  if (!this.shadowEnabled) {
2157
3714
  return;
@@ -2219,6 +3776,12 @@ var S3SitemapStorage = class {
2219
3776
  }
2220
3777
  }
2221
3778
  }
3779
+ /**
3780
+ * Lists all archived versions of a specific sitemap in S3.
3781
+ *
3782
+ * @param filename - The sitemap filename.
3783
+ * @returns A promise resolving to an array of version identifiers.
3784
+ */
2222
3785
  async listVersions(filename) {
2223
3786
  if (this.shadowMode !== "versioned") {
2224
3787
  return [];
@@ -2251,6 +3814,12 @@ var S3SitemapStorage = class {
2251
3814
  return [];
2252
3815
  }
2253
3816
  }
3817
+ /**
3818
+ * Reverts a sitemap to a previously archived version in S3.
3819
+ *
3820
+ * @param filename - The sitemap filename.
3821
+ * @param version - The version identifier to switch to.
3822
+ */
2254
3823
  async switchVersion(filename, version) {
2255
3824
  if (this.shadowMode !== "versioned") {
2256
3825
  throw new Error("Version switching is only available in versioned mode");
@@ -2279,6 +3848,7 @@ export {
2279
3848
  GenerateSitemapJob,
2280
3849
  IncrementalGenerator,
2281
3850
  MemoryChangeTracker,
3851
+ MemoryLock,
2282
3852
  MemoryProgressStorage,
2283
3853
  MemoryRedirectManager,
2284
3854
  MemorySitemapStorage,
@@ -2287,6 +3857,7 @@ export {
2287
3857
  RedirectDetector,
2288
3858
  RedirectHandler,
2289
3859
  RedisChangeTracker,
3860
+ RedisLock,
2290
3861
  RedisProgressStorage,
2291
3862
  RedisRedirectManager,
2292
3863
  RouteScanner,
@@ -2295,6 +3866,10 @@ export {
2295
3866
  SitemapGenerator,
2296
3867
  SitemapIndex,
2297
3868
  SitemapStream,
3869
+ compressToBuffer,
3870
+ createCompressionStream,
3871
+ fromGzipFilename,
2298
3872
  generateI18nEntries,
2299
- routeScanner
3873
+ routeScanner,
3874
+ toGzipFilename
2300
3875
  };