~james/core

core/sitemap/src/xml.ts -rw-r--r-- 2.6 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 16 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
import type { ShardInfo, UrlEntry } from './types';

const XML_HEADER = '<?xml version="1.0" encoding="UTF-8"?>';
const SITEMAP_SCHEMA = 'http://www.sitemaps.org/schemas/sitemap/0.9';
const TANGLED_EPOCH = new Date('2025-01-01T00:00:00Z');

export const PROTOCOL_MAX_URLS = 50000;
export const PROTOCOL_MAX_BYTES = 50 * 1024 * 1024; // 50 MB uncompressed

export function escapeXml(str: string): string {
  return str
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&apos;');
}

export function sanitizeLastmod(rawDate?: string, referenceTime = new Date()): string {
  if (!rawDate) {
    return referenceTime.toISOString().split('T')[0];
  }

  const parsed = new Date(rawDate);
  if (isNaN(parsed.getTime()) || parsed > referenceTime || parsed < TANGLED_EPOCH) {
    return referenceTime.toISOString().split('T')[0];
  }

  return parsed.toISOString().split('T')[0];
}

export function measureXmlBytes(xml: string): number {
  return new TextEncoder().encode(xml).byteLength;
}

export function buildUrlsetXml(urls: UrlEntry[]): string {
  const body = urls
    .map((u) => {
      const parts = [`    <loc>${escapeXml(u.loc)}</loc>`];
      if (u.lastmod) parts.push(`    <lastmod>${escapeXml(u.lastmod)}</lastmod>`);
      if (u.changefreq) parts.push(`    <changefreq>${u.changefreq}</changefreq>`);
      if (u.priority !== undefined) parts.push(`    <priority>${u.priority.toFixed(1)}</priority>`);
      return `  <url>\n${parts.join('\n')}\n  </url>`;
    })
    .join('\n');

  return `${XML_HEADER}\n<urlset xmlns="${SITEMAP_SCHEMA}">\n${body}\n</urlset>\n`;
}

export function buildSitemapIndexXml(shards: ShardInfo[], baseUrl: string): string {
  const body = shards
    .map((shard) => {
      const loc = `${baseUrl.replace(/\/+$/, '')}/sitemaps/${shard.filename}`;
      return `  <sitemap>\n    <loc>${escapeXml(loc)}</loc>\n    <lastmod>${escapeXml(shard.lastmod)}</lastmod>\n  </sitemap>`;
    })
    .join('\n');

  return `${XML_HEADER}\n<sitemapindex xmlns="${SITEMAP_SCHEMA}">\n${body}\n</sitemapindex>\n`;
}

export async function gzipCompress(text: string): Promise<Uint8Array> {
  const blob = new Blob([text]);
  const stream = blob.stream().pipeThrough(new CompressionStream('gzip'));
  const buffer = await new Response(stream).arrayBuffer();
  return new Uint8Array(buffer);
}

export async function gzipDecompress(data: ArrayBuffer | Uint8Array): Promise<string> {
  const blob = new Blob([data as any]);
  const stream = blob.stream().pipeThrough(new DecompressionStream('gzip'));
  return await new Response(stream).text();
}