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. (Seeopenspec/AGENTS.mdSection 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 differentlocs on the sameurlEntry - THEN
urlEntry.imagesis[{ 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 throughSitemapImageSchema.parsewithout loss
Scenario: 1,001st image throws IMAGE_VALIDATION_FAILED
- WHEN 1,001 calls to
addImageare issued for the sameurlEntry - THEN the 1,001st throws
SitemapErrorwith codeSITEMAP.IMAGE_VALIDATION_FAILEDandcontext.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 neithercontentLocnorplayerLoc - THEN it throws
SitemapErrorwith codeSITEMAP.VIDEO_VALIDATION_FAILEDandcontext.field === "contentLoc|playerLoc"
Scenario: rating range enforced (0.0 to 5.0)
- WHEN
addVideois called withrating: 5.5 - THEN it throws
SitemapErrorwith codeSITEMAP.VIDEO_VALIDATION_FAILEDandcontext.field === "rating"
Scenario: duration range enforced (1 to 28800 seconds)
- WHEN
addVideois called withduration: 0 - THEN it throws
SitemapErrorwith codeSITEMAP.VIDEO_VALIDATION_FAILEDandcontext.field === "duration"
Scenario: full video round-trips through schema
- WHEN
addVideois called with every optional field populated - THEN
urlEntry.videos[0]round-trips throughSitemapVideoSchema.parsewithout 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) andsitemap.xml(the index pointing atsitemap-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), andsitemap.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, notsitemap-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
SitemapErrorwith codeSITEMAP.INCREMENTAL_ROLLOVER_FAILEDandcontext.causecarrying 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: trueis set and a fakeClockreturning1_700_000_000_000is 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"andbaseFilename: "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
compressSitemapthendecompressGzipSitemap - 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
SitemapErrorwith codeSITEMAP.COMPRESSION_FAILEDandcontext.causecarrying 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"