Initial Commit
This commit is contained in:
140
openspec/changes/sitemap-completion/specs/sitemap/spec.md
Normal file
140
openspec/changes/sitemap-completion/specs/sitemap/spec.md
Normal file
@@ -0,0 +1,140 @@
|
||||
<!-- 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 -->
|
||||
Reference in New Issue
Block a user