# 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 `` 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 `loc`s 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 `` 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`, `compressSitemapIndex(xml: string): Promise`, and `decompressGzipSitemap(bytes: Uint8Array): Promise`. 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("")` 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("")` 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"`