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

141 lines
9.2 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# 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 `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 `<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"`
<!-- markdownlint-enable MD024 -->