Files
2026-05-27 23:29:53 +01:00

9.2 KiB

Capability Spec Delta: sitemap (sitemap-completion)

Package: @suaveplan/sitemap Status: PROPOSED

Every requirement below carries a **Verified by:** line at authoring time. (See openspec/AGENTS.md Section 13.)

ADDED Requirements

Requirement: Image-sitemap extension

The package SHALL expose a SitemapImageExtension class with addImage(urlEntry, image) that mutates the given SitemapUrl to include a <image:image> block per Google's image-sitemap spec. Each image SHALL support loc (required), caption, geoLocation, title, and license. Up to 1,000 images per URL SHALL be permitted (Google's hard cap); the 1,001st throws.

Scenario: addImage appends to images array in order

  • WHEN addImage(urlEntry, { loc: "https://example.com/a.jpg" }) is called twice with different locs on the same urlEntry
  • THEN urlEntry.images is [{ loc: ".../a.jpg" }, { loc: ".../b.jpg" }] in insertion order

Scenario: full optional set survives Zod validation

  • WHEN addImage(urlEntry, { loc, caption, geoLocation, title, license }) is called with all fields populated
  • THEN the resulting urlEntry.images[0] round-trips through SitemapImageSchema.parse without loss

Scenario: 1,001st image throws IMAGE_VALIDATION_FAILED

  • WHEN 1,001 calls to addImage are issued for the same urlEntry
  • THEN the 1,001st throws SitemapError with code SITEMAP.IMAGE_VALIDATION_FAILED and context.imageCount === 1001

Verified by: packages/api/sitemap/src/extensions/image-sitemap/image-sitemap.test.ts::"appends images in insertion order" AND packages/api/sitemap/src/extensions/image-sitemap/image-sitemap.test.ts::"round-trips full optional set" AND packages/api/sitemap/src/extensions/image-sitemap/image-sitemap.test.ts::"throws IMAGE_VALIDATION_FAILED at 1001 images"


Requirement: Video-sitemap extension

The package SHALL expose a SitemapVideoExtension class with addVideo(urlEntry, video) that mutates the given SitemapUrl to include a <video:video> block per Google's video-sitemap spec. Each video SHALL support thumbnail (required), title (required), description (required), contentLoc OR playerLoc (at least one required), duration, expiration, rating, viewCount, publicationDate, familyFriendly, requiresSubscription, uploader, and live.

Scenario: video without contentLoc or playerLoc rejected

  • WHEN addVideo(urlEntry, { thumbnail, title, description }) is called with neither contentLoc nor playerLoc
  • THEN it throws SitemapError with code SITEMAP.VIDEO_VALIDATION_FAILED and context.field === "contentLoc|playerLoc"

Scenario: rating range enforced (0.0 to 5.0)

  • WHEN addVideo is called with rating: 5.5
  • THEN it throws SitemapError with code SITEMAP.VIDEO_VALIDATION_FAILED and context.field === "rating"

Scenario: duration range enforced (1 to 28800 seconds)

  • WHEN addVideo is called with duration: 0
  • THEN it throws SitemapError with code SITEMAP.VIDEO_VALIDATION_FAILED and context.field === "duration"

Scenario: full video round-trips through schema

  • WHEN addVideo is called with every optional field populated
  • THEN urlEntry.videos[0] round-trips through SitemapVideoSchema.parse without loss

Verified by: packages/api/sitemap/src/extensions/video-sitemap/video-sitemap.test.ts::"rejects video missing both contentLoc and playerLoc" AND packages/api/sitemap/src/extensions/video-sitemap/video-sitemap.test.ts::"enforces rating range" AND packages/api/sitemap/src/extensions/video-sitemap/video-sitemap.test.ts::"enforces duration range" AND packages/api/sitemap/src/extensions/video-sitemap/video-sitemap.test.ts::"round-trips full optional set"


Requirement: Incremental on-disk sitemap builder

The package SHALL expose an IncrementalSitemapBuilder class that streams URLs through an injectable WritableStorageLike adapter, preserving insertion order, rolling over to a new sitemap file at 50,000 URLs OR 50 MiB (whichever first), and emitting one SitemapIndex entry per rollover. A finalize() method SHALL flush the in-progress sitemap, write the assembled index file, and return the index path.

Scenario: append + finalize produces single sitemap when under cap

  • WHEN 100 URLs are appended and finalize() is called
  • THEN the storage adapter receives exactly 2 writes: sitemap-1.xml (the URLs) and sitemap.xml (the index pointing at sitemap-1.xml)

Scenario: rollover at exactly 50,000 URLs

  • WHEN 50,001 URLs are appended (49,999 → 50,000 → 50,001) and finalize() is called
  • THEN the storage adapter receives writes for sitemap-1.xml (URLs 1-50000), sitemap-2.xml (URL 50001), and sitemap.xml (the index with 2 entries)

Scenario: rollover at 50 MiB

  • WHEN URLs are appended whose cumulative XML byte size crosses 50 MiB before reaching 50,000 URLs
  • THEN the builder rolls over at the URL whose append would exceed 50 MiB; that URL goes into sitemap-2.xml, not sitemap-1.xml

Scenario: finalize is idempotent

  • WHEN finalize() is called twice in succession
  • THEN the second call returns the same index path AND the storage adapter does not receive any additional writes (no double-emit)

Scenario: rollover error is structured

  • WHEN the storage adapter throws on a rollover write
  • THEN the builder re-throws as SitemapError with code SITEMAP.INCREMENTAL_ROLLOVER_FAILED and context.cause carrying the original error

Verified by: packages/api/sitemap/src/incremental/incremental-builder/incremental-builder.test.ts::"appends and finalizes a single sitemap when under cap" AND packages/api/sitemap/src/incremental/incremental-builder/incremental-builder.test.ts::"rolls over at exactly 50000 URLs" AND packages/api/sitemap/src/incremental/incremental-builder/incremental-builder.test.ts::"rolls over at 50 MiB byte cap" AND packages/api/sitemap/src/incremental/incremental-builder/incremental-builder.spec-conformance.test.ts::"finalize is idempotent" AND packages/api/sitemap/src/incremental/incremental-builder/incremental-builder.spec-conformance.test.ts::"wraps storage errors as INCREMENTAL_ROLLOVER_FAILED"


Requirement: Incremental builder honours injected Clock and storage

The package SHALL accept an injectable Clock from @suaveplan/types (default: system clock) for the gzip mtime field and any timestamps emitted in OTel metrics, and an injectable WritableStorageLike (no default — required).

Scenario: injected clock controls gzip mtime

  • WHEN compress: true is set and a fake Clock returning 1_700_000_000_000 is injected
  • THEN the gzip header for each rollover output carries mtime 1_700_000_000 (seconds, gzip header convention)

Scenario: storage adapter receives all expected paths

  • WHEN 60,000 URLs are appended with outputDir: "/tmp/x" and baseFilename: "sm"
  • THEN the adapter sees writes to /tmp/x/sm-1.xml, /tmp/x/sm-2.xml, and /tmp/x/sm.xml (the index) — exactly three writes

Verified by: packages/api/sitemap/src/incremental/incremental-builder/incremental-builder.spec-conformance.test.ts::"injected clock controls gzip mtime" AND packages/api/sitemap/src/incremental/incremental-builder/incremental-builder.spec-conformance.test.ts::"storage adapter receives expected paths"


Requirement: gzip compression helpers

The package SHALL expose async helpers compressSitemap(xml: string): Promise<Uint8Array>, compressSitemapIndex(xml: string): Promise<Uint8Array>, and decompressGzipSitemap(bytes: Uint8Array): Promise<string>. The compressed output SHALL begin with the gzip magic bytes 1f 8b. The mtime header SHALL come from an injectable Clock (default: () => 0 for deterministic test fixtures).

Scenario: compress produces gzip magic bytes

  • WHEN compressSitemap("<urlset/>") is awaited
  • THEN the first two bytes of the result are 0x1f 0x8b

Scenario: round-trip preserves XML byte-for-byte

  • WHEN an arbitrary XML string is passed through compressSitemap then decompressGzipSitemap
  • THEN the output equals the input string

Scenario: compression failure surfaces as structured error

  • WHEN the underlying gzip implementation rejects the input (e.g. injected mock throws)
  • THEN the helper throws SitemapError with code SITEMAP.COMPRESSION_FAILED and context.cause carrying the original error

Scenario: deterministic mtime under default clock

  • WHEN compressSitemap("<urlset/>") is called twice with the default clock (() => 0)
  • THEN both outputs are byte-for-byte identical

Verified by: packages/api/sitemap/src/compression/sitemap-compressor/sitemap-compressor.test.ts::"emits gzip magic bytes" AND packages/api/sitemap/src/compression/sitemap-compressor/sitemap-compressor.test.ts::"round-trips XML byte-for-byte" AND packages/api/sitemap/src/compression/sitemap-compressor/sitemap-compressor.test.ts::"wraps gzip errors as COMPRESSION_FAILED" AND packages/api/sitemap/src/compression/sitemap-compressor/sitemap-compressor.test.ts::"produces deterministic output under default clock"