Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,69 @@
# @suaveplan/sitemap - Sitemap generation and parsing: sitemap.xml, sitemap index, image sitemaps, video sitemaps per sitemaps.org protocol
## Summary
Sitemap generation and parsing for the sitemaps.org XML protocol. Produces valid `sitemap.xml`, sitemap index documents, and parses existing sitemaps. Supports Google image and video extensions as well as hreflang alternate links.
<!-- begin:auto-docs -->
## Documentation tree
- [builder](./src/core/builder/sitemap-builder.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sitemap-builder](./src/core/builder/sitemap-builder.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [index-builder](./src/core/index-builder/index-builder.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [index-builder](./src/core/index-builder/index-builder.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [parser](./src/core/parser/sitemap-parser.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sitemap-parser](./src/core/parser/sitemap-parser.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/sitemap-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sitemap-error](./src/errors/sitemap-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/schemas/sitemap.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [sitemap.schema](./src/schemas/sitemap.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [utils](./src/utils/url-utils.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [url-utils](./src/utils/url-utils.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [sitemap-builder](./src/core/builder/sitemap-builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [index-builder](./src/core/index-builder/index-builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [index-builder](./src/core/index-builder/index-builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [parser](./src/core/parser/sitemap-parser.md) — <!-- begin:auto-docs --> _(no submodules)_- [sitemap-parser](./src/core/parser/sitemap-parser.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/sitemap-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [sitemap-error](./src/errors/sitemap-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/sitemap.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [sitemap.schema](./src/schemas/sitemap.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/url-utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [url-utils](./src/utils/url-utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [sitemap-builder](./src/core/builder/sitemap-builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [index-builder](./src/core/index-builder/index-builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [index-builder](./src/core/index-builder/index-builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [parser](./src/core/parser/sitemap-parser.md) — <!-- begin:auto-docs --> _(no submodules)_- [sitemap-parser](./src/core/parser/sitemap-parser.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/sitemap-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [sitemap-error](./src/errors/sitemap-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/sitemap.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [sitemap.schema](./src/schemas/sitemap.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/url-utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [url-utils](./src/utils/url-utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [sitemap-builder](./src/core/builder/sitemap-builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [index-builder](./src/core/index-builder/index-builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [index-builder](./src/core/index-builder/index-builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [parser](./src/core/parser/sitemap-parser.md) — <!-- begin:auto-docs --> _(no submodules)_- [sitemap-parser](./src/core/parser/sitemap-parser.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/sitemap-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [sitemap-error](./src/errors/sitemap-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/sitemap.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [sitemap.schema](./src/schemas/sitemap.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/url-utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [url-utils](./src/utils/url-utils.md) — <!-- begin:auto-docs --> _(no submodules)_- [sitemap-builder](./src/core/builder/sitemap-builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [index-builder](./src/core/index-builder/index-builder.md) — <!-- begin:auto-docs --> _(no submodules)_ - [index-builder](./src/core/index-builder/index-builder.md) — <!-- begin:auto-docs --> _(no submodules)_- [parser](./src/core/parser/sitemap-parser.md) — <!-- begin:auto-docs --> _(no submodules)_ - [sitemap-parser](./src/core/parser/sitemap-parser.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/sitemap-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [sitemap-error](./src/errors/sitemap-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/sitemap.schema.md) — <!-- begin:auto-docs --> _(no submodules)_ - [sitemap.schema](./src/schemas/sitemap.schema.md) — <!-- begin:auto-docs --> _(no submodules)_- [utils](./src/utils/url-utils.md) — <!-- begin:auto-docs --> _(no submodules)_ - [url-utils](./src/utils/url-utils.md) — <!-- begin:auto-docs --> _(no submodules)_
## Installation
```bash
bun add @suaveplan/sitemap
```
## Packages
| Module | Purpose |
|--------|---------|
| [sitemap-builder](src/core/builder/sitemap-builder.md) | Fluent builder for `<urlset>` sitemap documents |
| [index-builder](src/core/index-builder/index-builder.md) | Fluent builder for `<sitemapindex>` documents |
| [sitemap-parser](src/core/parser/sitemap-parser.md) | Parse sitemap and sitemap index XML strings |
| [url-utils](src/utils/url-utils.md) | URL validation, normalisation, XML escaping |
| [sitemap.schema](src/schemas/sitemap.schema.md) | Zod schemas for all wire-format types |
| [sitemap-error](src/errors/sitemap-error.md) | Error codes and `SitemapError` class |
## Quick Start
```ts
import { createSitemap, createSitemapIndex, parseSitemap } from "@suaveplan/sitemap";
// Build a sitemap
const xml = createSitemap({ pretty: true })
.add({ loc: "https://example.com/", changefreq: "weekly", priority: 1.0 })
.add({ loc: "https://example.com/about", lastmod: "2024-01-15" })
.build();
// Build a sitemap index
const indexXml = createSitemapIndex({ pretty: true })
.add("https://example.com/sitemap-blog.xml", "2024-06-01")
.add("https://example.com/sitemap-products.xml")
.build();
// Parse an existing sitemap
const urls = parseSitemap(xml);
console.log(urls[0].loc); // "https://example.com/"
```
## Subpath Exports
```ts
import { SitemapBuilder, createSitemap } from "@suaveplan/sitemap/builder";
import { SitemapIndexBuilder, createSitemapIndex } from "@suaveplan/sitemap/index-builder";
import { parseSitemap, parseSitemapIndex } from "@suaveplan/sitemap/parser";
import { SitemapError, SITEMAP_CODES } from "@suaveplan/sitemap/errors";
```

View File

@@ -0,0 +1,101 @@
{
"name": "@suaveplan/sitemap",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Sitemap generation and parsing: sitemap.xml, sitemap index, image sitemaps, video sitemaps per sitemaps.org protocol",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./builder": {
"import": {
"types": "./dist/core/builder/sitemap-builder.d.ts",
"default": "./dist/core/builder/sitemap-builder.js"
},
"require": {
"types": "./dist/core/builder/sitemap-builder.d.ts",
"default": "./dist/core/builder/sitemap-builder.js"
}
},
"./index-builder": {
"import": {
"types": "./dist/core/index-builder/index-builder.d.ts",
"default": "./dist/core/index-builder/index-builder.js"
},
"require": {
"types": "./dist/core/index-builder/index-builder.d.ts",
"default": "./dist/core/index-builder/index-builder.js"
}
},
"./parser": {
"import": {
"types": "./dist/core/parser/sitemap-parser.d.ts",
"default": "./dist/core/parser/sitemap-parser.js"
},
"require": {
"types": "./dist/core/parser/sitemap-parser.d.ts",
"default": "./dist/core/parser/sitemap-parser.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/sitemap-error.d.ts",
"default": "./dist/errors/sitemap-error.js"
},
"require": {
"types": "./dist/errors/sitemap-error.d.ts",
"default": "./dist/errors/sitemap-error.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"fast-xml-parser": "catalog:parsing",
"zod": "catalog:forms",
"tslib": "catalog:build",
"@opentelemetry/api": "catalog:observability"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,5 @@
export {
createSitemap,
SitemapBuilder,
type SitemapBuilderOptions,
} from "./sitemap-builder";

View File

@@ -0,0 +1,152 @@
# sitemap-builder
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the fluent `SitemapBuilder` class and the `createSitemap` factory
function that together provide the primary interface for producing
sitemaps.org-conformant `<urlset>` XML strings. The builder accumulates
`SitemapUrl` entries, validates each location, enforces the 50 000 URL ceiling
(per the sitemaps.org protocol), and serialises the full document with optional
pretty-printing. Google image and video sitemap extensions are rendered
automatically when the corresponding fields are present on a `SitemapUrl`.
## Features
- Fluent `add(url)` / `addMany(urls[])` API returning `this` for chaining
- `maxUrls` guard (default 50 000) with `SIZE_EXCEEDED` error
- Automatic `xmlns:image`, `xmlns:video`, `xmlns:xhtml` namespace declarations
- Image extension: `loc`, `caption`, `title`, `geoLocation`, `license`
- Video extension: full sitemaps.org video schema including tags, ratings, subscription flags
- hreflang alternates via `<xhtml:link rel="alternate" …/>`
- Pretty-print mode with two-space indentation
- `toJSON()` snapshot, `clear()` reset, `count` getter
- OTel counter `sitemap.build.total{type="urlset"}` and histogram `sitemap.build.duration_ms`
## Usage: Basic Example
```ts
import { createSitemap } from "@suaveplan/sitemap/builder";
const xml = createSitemap({ pretty: true })
.add({ loc: "https://example.com/", changefreq: "weekly", priority: 1.0 })
.add({ loc: "https://example.com/about", lastmod: "2024-01-15" })
.build();
console.log(xml);
// <?xml version="1.0" encoding="UTF-8"?>
// <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" …>
// <url>
// <loc>https://example.com/</loc>
// …
// </url>
// </urlset>
```
## Usage: Advanced Example
```ts
import { createSitemap, SitemapBuilder } from "@suaveplan/sitemap/builder";
import type { SitemapUrl } from "@suaveplan/sitemap";
// Builder with a tight limit for a subsection sitemap
const builder: SitemapBuilder = new SitemapBuilder({ maxUrls: 1000, pretty: false });
const urls: SitemapUrl[] = [
{
loc: "https://example.com/gallery/photo-1",
images: [
{ loc: "https://cdn.example.com/photo-1.jpg", caption: "Sunset view", title: "Photo 1" },
],
},
{
loc: "https://example.com/videos/intro",
videos: [
{
thumbnailLoc: "https://cdn.example.com/intro-thumb.jpg",
title: "Intro video",
description: "A short introduction to our product",
duration: 120,
rating: 4.8,
familyFriendly: true,
tags: ["intro", "product"],
},
],
},
{
loc: "https://example.com/en/home",
alternates: [
{ hreflang: "fr", href: "https://example.com/fr/home" },
{ hreflang: "de", href: "https://example.com/de/home" },
],
},
];
builder.addMany(urls);
console.log(`Built ${builder.count} URLs`); // "Built 3 URLs"
const xml = builder.build();
builder.clear();
```
## API Reference
### `SitemapBuilderOptions`
| Field | Type | Default | Description |
|-----------|----------------------------|-----------|-----------------------------------------|
| `maxUrls` | `number` | `50000` | Maximum URL count; throws `SIZE_EXCEEDED` when exceeded |
| `xmlns` | `Record<string, string>` | `{}` | Extra namespace prefix → URI pairs |
| `pretty` | `boolean` | `false` | Indent XML output |
### `class SitemapBuilder`
#### `constructor(options?: SitemapBuilderOptions)`
Throws `SitemapError(INVALID_OPTIONS)` when `maxUrls < 1`.
#### `add(url: SitemapUrl): this`
Appends one URL entry. Throws `SitemapError(SIZE_EXCEEDED)` when at capacity.
Throws `SitemapError(INVALID_URL)` during `build()` if the URL is not a valid
absolute `http/https` URL.
#### `addMany(urls: SitemapUrl[]): this`
Calls `add()` for each entry in order.
#### `build(): string`
Returns the complete XML document string. Validates each URL's `loc` field;
throws `SitemapError(INVALID_URL)` on the first invalid entry. Emits OTel
metrics.
#### `clear(): this`
Empties the internal URL list. Options are preserved.
#### `get count: number`
Returns the current number of accumulated URLs.
#### `toJSON(): SitemapUrl[]`
Returns a shallow copy of the internal URL array.
### `createSitemap(options?: SitemapBuilderOptions): SitemapBuilder`
Convenience factory; equivalent to `new SitemapBuilder(options)`.
## Implementation Notes
Namespace declarations for image, video, and xhtml are unconditionally included
in the `<urlset>` element. This matches the practice of major sitemap generators
and avoids a two-pass scan of URL content to discover which namespaces are
needed. The overhead is a few bytes and eliminates the complexity of conditional
namespace injection.
XML serialisation is done via string concatenation rather than a DOM or
`XMLBuilder`, which keeps the dependency footprint minimal and the output
deterministic. All user-supplied string values are run through `escapeXml`
before inclusion in the output.

View File

@@ -0,0 +1,351 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SITEMAP_CODES, SitemapError } from "../../errors/sitemap-error.js";
import { createSitemap, SitemapBuilder } from "./sitemap-builder.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function _parseXml(xml: string): Document {
const { DOMParser } = globalThis as unknown as {
DOMParser: typeof DOMParser;
};
if (typeof DOMParser !== "undefined") {
return new DOMParser().parseFromString(xml, "text/xml");
}
// Node/Bun: use @xmldom/xmldom if available; otherwise just check strings
throw new Error("DOMParser not available");
}
function hasNamespace(xml: string, ns: string): boolean {
return xml.includes(ns);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("SitemapBuilder", () => {
it("empty sitemap produces valid XML with urlset root", {
timeout: 5000,
}, () => {
const xml = createSitemap().build();
expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>');
expect(xml).toContain("<urlset ");
expect(xml).toContain("</urlset>");
expect(xml).toContain("http://www.sitemaps.org/schemas/sitemap/0.9");
});
it("always includes image, video, and xhtml namespaces", {
timeout: 5000,
}, () => {
const xml = createSitemap().build();
expect(xml).toContain(
'xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"'
);
expect(xml).toContain(
'xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"'
);
expect(xml).toContain('xmlns:xhtml="http://www.w3.org/1999/xhtml"');
});
it("single URL with minimal fields produces correct XML", {
timeout: 5000,
}, () => {
const xml = createSitemap()
.add({ loc: "https://example.com/" })
.build();
expect(xml).toContain("<loc>https://example.com/</loc>");
expect(xml).toContain("<url>");
expect(xml).toContain("</url>");
});
it("single URL with all standard fields", { timeout: 5000 }, () => {
const xml = createSitemap({ pretty: true })
.add({
loc: "https://example.com/page",
lastmod: "2024-01-15",
changefreq: "daily",
priority: 0.8,
})
.build();
expect(xml).toContain("<loc>https://example.com/page</loc>");
expect(xml).toContain("<lastmod>2024-01-15</lastmod>");
expect(xml).toContain("<changefreq>daily</changefreq>");
expect(xml).toContain("<priority>0.80</priority>");
});
it("count tracks added URLs", { timeout: 5000 }, () => {
const builder = createSitemap();
expect(builder.count).toBe(0);
builder.add({ loc: "https://example.com/a" });
expect(builder.count).toBe(1);
builder.add({ loc: "https://example.com/b" });
expect(builder.count).toBe(2);
});
it("addMany adds all URLs", { timeout: 5000 }, () => {
const builder = createSitemap();
builder.addMany([
{ loc: "https://example.com/1" },
{ loc: "https://example.com/2" },
{ loc: "https://example.com/3" },
]);
expect(builder.count).toBe(3);
});
it("clear resets the builder", { timeout: 5000 }, () => {
const builder = createSitemap();
builder.add({ loc: "https://example.com/" });
expect(builder.count).toBe(1);
builder.clear();
expect(builder.count).toBe(0);
});
it("toJSON returns a copy of accumulated URLs", { timeout: 5000 }, () => {
const builder = createSitemap();
builder.add({ loc: "https://example.com/" });
const json = builder.toJSON();
expect(json).toHaveLength(1);
expect(json[0].loc).toBe("https://example.com/");
// Mutation does not affect builder
json.push({ loc: "https://example.com/extra" });
expect(builder.count).toBe(1);
});
it("maxUrls limit throws SIZE_EXCEEDED", { timeout: 5000 }, () => {
const builder = new SitemapBuilder({ maxUrls: 2 });
builder.add({ loc: "https://example.com/1" });
builder.add({ loc: "https://example.com/2" });
expect(() => builder.add({ loc: "https://example.com/3" })).toThrow(
SitemapError
);
expect(() => builder.add({ loc: "https://example.com/3" })).toThrow(
expect.objectContaining({ code: SITEMAP_CODES.SIZE_EXCEEDED })
);
});
it("invalid maxUrls throws INVALID_OPTIONS", { timeout: 5000 }, () => {
expect(() => new SitemapBuilder({ maxUrls: 0 })).toThrow(SitemapError);
expect(() => new SitemapBuilder({ maxUrls: 0 })).toThrow(
expect.objectContaining({ code: SITEMAP_CODES.INVALID_OPTIONS })
);
});
it("invalid URL throws INVALID_URL during build", { timeout: 5000 }, () => {
const builder = createSitemap();
// Bypass the schema to insert a bad URL
(builder as unknown as { _urls: Array<{ loc: string }> })._urls.push({
loc: "ftp://bad.com/",
});
expect(() => builder.build()).toThrow(SitemapError);
expect(() => builder.build()).toThrow(
expect.objectContaining({ code: SITEMAP_CODES.INVALID_URL })
);
});
it("image extension renders correct namespace and elements", {
timeout: 5000,
}, () => {
const xml = createSitemap({ pretty: true })
.add({
loc: "https://example.com/gallery",
images: [
{
loc: "https://cdn.example.com/photo.jpg",
caption: "A nice photo",
title: "Photo Title",
geoLocation: "Paris, FR",
license: "https://creativecommons.org/licenses/by/4.0/",
},
],
})
.build();
expect(
hasNamespace(xml, "http://www.google.com/schemas/sitemap-image/1.1")
).toBe(true);
expect(xml).toContain("<image:image>");
expect(xml).toContain(
"<image:loc>https://cdn.example.com/photo.jpg</image:loc>"
);
expect(xml).toContain("<image:caption>A nice photo</image:caption>");
expect(xml).toContain("<image:title>Photo Title</image:title>");
expect(xml).toContain(
"<image:geo_location>Paris, FR</image:geo_location>"
);
expect(xml).toContain(
"<image:license>https://creativecommons.org/licenses/by/4.0/</image:license>"
);
expect(xml).toContain("</image:image>");
});
it("video extension renders correct namespace and elements", {
timeout: 5000,
}, () => {
const xml = createSitemap({ pretty: true })
.add({
loc: "https://example.com/video",
videos: [
{
thumbnailLoc: "https://cdn.example.com/thumb.jpg",
title: "Product demo",
description: "Walkthrough of features",
duration: 180,
rating: 4.5,
viewCount: 1000,
familyFriendly: true,
requiresSubscription: false,
live: false,
publicationDate: "2024-01-01",
expirationDate: "2025-01-01",
contentLoc: "https://cdn.example.com/video.mp4",
playerLoc: "https://player.example.com/",
tags: ["demo", "features"],
},
],
})
.build();
expect(
hasNamespace(xml, "http://www.google.com/schemas/sitemap-video/1.1")
).toBe(true);
expect(xml).toContain("<video:video>");
expect(xml).toContain(
"<video:thumbnail_loc>https://cdn.example.com/thumb.jpg</video:thumbnail_loc>"
);
expect(xml).toContain("<video:title>Product demo</video:title>");
expect(xml).toContain(
"<video:description>Walkthrough of features</video:description>"
);
expect(xml).toContain("<video:duration>180</video:duration>");
expect(xml).toContain("<video:rating>4.5</video:rating>");
expect(xml).toContain("<video:view_count>1000</video:view_count>");
expect(xml).toContain(
"<video:family_friendly>yes</video:family_friendly>"
);
expect(xml).toContain(
"<video:requires_subscription>no</video:requires_subscription>"
);
expect(xml).toContain("<video:live>no</video:live>");
expect(xml).toContain("<video:tag>demo</video:tag>");
expect(xml).toContain("<video:tag>features</video:tag>");
expect(xml).toContain("</video:video>");
});
it("hreflang alternates render xhtml:link elements", {
timeout: 5000,
}, () => {
const xml = createSitemap({ pretty: true })
.add({
loc: "https://example.com/en/home",
alternates: [
{ hreflang: "fr", href: "https://example.com/fr/home" },
{ hreflang: "x-default", href: "https://example.com/" },
],
})
.build();
expect(xml).toContain('rel="alternate"');
expect(xml).toContain('hreflang="fr"');
expect(xml).toContain('href="https://example.com/fr/home"');
expect(xml).toContain('hreflang="x-default"');
});
it("extra xmlns options appear in urlset element", {
timeout: 5000,
}, () => {
const xml = createSitemap({
xmlns: { news: "http://www.google.com/schemas/sitemap-news/0.9" },
}).build();
expect(xml).toContain(
'xmlns:news="http://www.google.com/schemas/sitemap-news/0.9"'
);
});
it("pretty=false produces compact single-line-ish XML", {
timeout: 5000,
}, () => {
const xml = createSitemap({ pretty: false })
.add({ loc: "https://example.com/" })
.build();
// Compact XML should not have leading newlines between major tags
expect(xml).not.toContain("\n <url>");
});
it("escapes special XML characters in URL fields", {
timeout: 5000,
}, () => {
const xml = createSitemap({ pretty: false })
.add({
loc: "https://example.com/page?a=1&b=2",
images: [
{
loc: "https://cdn.example.com/image.jpg",
caption: "A & B <test>",
},
],
})
.build();
expect(xml).toContain("https://example.com/page?a=1&amp;b=2");
expect(xml).toContain("A &amp; B &lt;test&gt;");
});
it("priority is clamped and formatted to 2 decimal places", {
timeout: 5000,
}, () => {
const xml = createSitemap()
.add({ loc: "https://example.com/", priority: 0.333 })
.build();
expect(xml).toContain("<priority>0.33</priority>");
});
it("chaining returns the same builder instance", { timeout: 5000 }, () => {
const builder = createSitemap();
const result = builder.add({ loc: "https://example.com/" });
expect(result).toBe(builder);
});
it("image with only loc (no optional fields) renders correctly", {
timeout: 5000,
}, () => {
// Covers false branches for caption/title/geoLocation/license
const xml = createSitemap({ pretty: true })
.add({
loc: "https://example.com/bare-image",
images: [{ loc: "https://cdn.example.com/img.jpg" }],
})
.build();
expect(xml).toContain("<image:image>");
expect(xml).toContain(
"<image:loc>https://cdn.example.com/img.jpg</image:loc>"
);
expect(xml).not.toContain("<image:caption>");
expect(xml).not.toContain("<image:title>");
});
it("video with only required fields (no optional) renders correctly", {
timeout: 5000,
}, () => {
// Covers false branches for contentLoc/playerLoc/duration/publicationDate/
// expirationDate/rating/viewCount/familyFriendly/requiresSubscription/live/tags
const xml = createSitemap({ pretty: true })
.add({
loc: "https://example.com/minimal-video",
videos: [
{
thumbnailLoc: "https://cdn.example.com/thumb.jpg",
title: "Minimal Video",
description: "No optional fields",
},
],
})
.build();
expect(xml).toContain("<video:video>");
expect(xml).toContain("<video:title>Minimal Video</video:title>");
expect(xml).not.toContain("<video:duration>");
expect(xml).not.toContain("<video:rating>");
expect(xml).not.toContain("<video:family_friendly>");
expect(xml).not.toContain("<video:tag>");
});
});

View File

@@ -0,0 +1,385 @@
import type { Attributes, Meter } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
import { SITEMAP_CODES, SitemapError } from "../../errors/sitemap-error.js";
import type {
SitemapAlternate,
SitemapImage,
SitemapUrl,
SitemapVideo,
} from "../../schemas/sitemap.schema.js";
import {
escapeXml,
formatPriority,
isValidUrl,
} from "../../utils/url-utils.js";
/** Default and maximum URL count per the sitemaps.org specification. */
const DEFAULT_MAX_URLS = 50_000;
/** Core XML namespaces always included in the urlset element. */
const NS_SITEMAP = "http://www.sitemaps.org/schemas/sitemap/0.9";
const NS_IMAGE = "http://www.google.com/schemas/sitemap-image/1.1";
const NS_VIDEO = "http://www.google.com/schemas/sitemap-video/1.1";
const NS_XHTML = "http://www.w3.org/1999/xhtml";
/**
* Options for `SitemapBuilder` / `createSitemap`.
*/
export interface SitemapBuilderOptions {
/**
* Maximum number of URLs the builder will accept before throwing
* `SitemapError` with code `SIZE_EXCEEDED`.
* @default 50000
*/
maxUrls?: number;
/**
* Additional XML namespace declarations to include on the `<urlset>`
* element. Keys are namespace prefix names (without `xmlns:`); values are
* the namespace URIs.
*
* The standard `xmlns`, `xmlns:image`, `xmlns:video`, and `xmlns:xhtml`
* namespaces are always included automatically.
*/
xmlns?: Record<string, string>;
/**
* When `true`, the builder will indent the XML output with two-space
* indentation for human readability.
* @default false
*/
pretty?: boolean;
}
// ---------------------------------------------------------------------------
// OTel
// ---------------------------------------------------------------------------
let _meter: Meter | undefined;
function getMeter(): Meter {
if (!_meter) {
_meter = metrics.getMeter("@suaveplan/sitemap", "0.1.0");
}
return _meter;
}
// ---------------------------------------------------------------------------
// XML helpers
// ---------------------------------------------------------------------------
function indent(level: number, pretty: boolean): string {
return pretty ? `\n${" ".repeat(level)}` : "";
}
function sep(pretty: boolean): string {
return pretty ? "\n" : "";
}
function renderImages(
images: SitemapImage[],
pretty: boolean,
lvl: number
): string {
return images
.map((img) => {
const i = indent(lvl, pretty);
const i2 = indent(lvl + 1, pretty);
const parts: string[] = [
`${i}<image:image>`,
`${i2}<image:loc>${escapeXml(img.loc)}</image:loc>`,
];
if (img.caption)
parts.push(
`${i2}<image:caption>${escapeXml(img.caption)}</image:caption>`
);
if (img.title)
parts.push(
`${i2}<image:title>${escapeXml(img.title)}</image:title>`
);
if (img.geoLocation)
parts.push(
`${i2}<image:geo_location>${escapeXml(img.geoLocation)}</image:geo_location>`
);
if (img.license)
parts.push(
`${i2}<image:license>${escapeXml(img.license)}</image:license>`
);
parts.push(`${i}</image:image>`);
return parts.join(sep(pretty));
})
.join(sep(pretty));
}
function boolStr(val: boolean): string {
return val ? "yes" : "no";
}
function renderVideos(
videos: SitemapVideo[],
pretty: boolean,
lvl: number
): string {
return videos
.map((vid) => {
const i = indent(lvl, pretty);
const i2 = indent(lvl + 1, pretty);
const parts: string[] = [
`${i}<video:video>`,
`${i2}<video:thumbnail_loc>${escapeXml(vid.thumbnailLoc)}</video:thumbnail_loc>`,
`${i2}<video:title>${escapeXml(vid.title)}</video:title>`,
`${i2}<video:description>${escapeXml(vid.description)}</video:description>`,
];
if (vid.contentLoc)
parts.push(
`${i2}<video:content_loc>${escapeXml(vid.contentLoc)}</video:content_loc>`
);
if (vid.playerLoc)
parts.push(
`${i2}<video:player_loc>${escapeXml(vid.playerLoc)}</video:player_loc>`
);
if (vid.duration !== undefined)
parts.push(
`${i2}<video:duration>${vid.duration}</video:duration>`
);
if (vid.publicationDate)
parts.push(
`${i2}<video:publication_date>${escapeXml(vid.publicationDate)}</video:publication_date>`
);
if (vid.expirationDate)
parts.push(
`${i2}<video:expiration_date>${escapeXml(vid.expirationDate)}</video:expiration_date>`
);
if (vid.rating !== undefined)
parts.push(
`${i2}<video:rating>${vid.rating.toFixed(1)}</video:rating>`
);
if (vid.viewCount !== undefined)
parts.push(
`${i2}<video:view_count>${vid.viewCount}</video:view_count>`
);
if (vid.familyFriendly !== undefined)
parts.push(
`${i2}<video:family_friendly>${boolStr(vid.familyFriendly)}</video:family_friendly>`
);
if (vid.requiresSubscription !== undefined)
parts.push(
`${i2}<video:requires_subscription>${boolStr(vid.requiresSubscription)}</video:requires_subscription>`
);
if (vid.live !== undefined)
parts.push(
`${i2}<video:live>${boolStr(vid.live)}</video:live>`
);
if (vid.tags) {
for (const tag of vid.tags) {
parts.push(`${i2}<video:tag>${escapeXml(tag)}</video:tag>`);
}
}
parts.push(`${i}</video:video>`);
return parts.join(sep(pretty));
})
.join(sep(pretty));
}
function renderAlternates(
alts: SitemapAlternate[],
pretty: boolean,
lvl: number
): string {
return alts
.map((alt) => {
const i = indent(lvl, pretty);
return `${i}<xhtml:link rel="alternate" hreflang="${escapeXml(alt.hreflang)}" href="${escapeXml(alt.href)}"/>`;
})
.join(sep(pretty));
}
function renderUrl(url: SitemapUrl, pretty: boolean): string {
const i1 = indent(1, pretty);
const i2 = indent(2, pretty);
if (!isValidUrl(url.loc)) {
throw new SitemapError(
`Invalid URL (must be absolute http/https): ${url.loc}`,
SITEMAP_CODES.INVALID_URL
);
}
const parts: string[] = [
`${i1}<url>`,
`${i2}<loc>${escapeXml(url.loc)}</loc>`,
];
if (url.lastmod)
parts.push(`${i2}<lastmod>${escapeXml(url.lastmod)}</lastmod>`);
if (url.changefreq)
parts.push(`${i2}<changefreq>${url.changefreq}</changefreq>`);
if (url.priority !== undefined)
parts.push(`${i2}<priority>${formatPriority(url.priority)}</priority>`);
if (url.images && url.images.length > 0) {
parts.push(renderImages(url.images, pretty, 2));
}
if (url.videos && url.videos.length > 0) {
parts.push(renderVideos(url.videos, pretty, 2));
}
if (url.alternates && url.alternates.length > 0) {
parts.push(renderAlternates(url.alternates, pretty, 2));
}
parts.push(`${i1}</url>`);
return parts.join(sep(pretty));
}
// ---------------------------------------------------------------------------
// SitemapBuilder
// ---------------------------------------------------------------------------
/**
* Fluent builder that accumulates `SitemapUrl` entries and serialises them to
* a valid sitemaps.org XML string.
*
* @example
* ```ts
* const xml = createSitemap({ pretty: true })
* .add({ loc: "https://example.com/", changefreq: "weekly", priority: 1.0 })
* .build();
* ```
*/
export class SitemapBuilder {
protected readonly _options: Required<
Omit<SitemapBuilderOptions, "xmlns">
> & {
xmlns: Record<string, string>;
};
protected _urls: SitemapUrl[] = [];
constructor(options: SitemapBuilderOptions = {}) {
const maxUrls = options.maxUrls ?? DEFAULT_MAX_URLS;
if (maxUrls < 1) {
throw new SitemapError(
`maxUrls must be at least 1, got ${maxUrls}`,
SITEMAP_CODES.INVALID_OPTIONS
);
}
this._options = {
maxUrls,
xmlns: options.xmlns ?? {},
pretty: options.pretty ?? false,
};
}
/**
* Adds a single URL entry.
*
* @param url - The URL entry to add.
* @throws {SitemapError} `SIZE_EXCEEDED` when the builder has already
* reached `maxUrls`.
*/
add(url: SitemapUrl): this {
if (this._urls.length >= this._options.maxUrls) {
throw new SitemapError(
`Sitemap exceeds the maximum of ${this._options.maxUrls} URLs`,
SITEMAP_CODES.SIZE_EXCEEDED
);
}
this._urls.push(url);
return this;
}
/**
* Adds multiple URL entries in one call.
*
* @param urls - Array of URL entries to add.
* @throws {SitemapError} `SIZE_EXCEEDED` when any URL would push the count
* over `maxUrls`.
*/
addMany(urls: SitemapUrl[]): this {
for (const url of urls) {
this.add(url);
}
return this;
}
/** Returns the number of URLs currently in the builder. */
get count(): number {
return this._urls.length;
}
/** Returns a shallow copy of the accumulated URL array. */
toJSON(): SitemapUrl[] {
return [...this._urls];
}
/** Removes all URLs from the builder without changing options. */
clear(): this {
this._urls = [];
return this;
}
/**
* Serialises the accumulated URLs to a complete sitemap XML string.
*
* Emits OTel metrics:
* - Counter `sitemap.build.total{type="urlset"}`
* - Histogram `sitemap.build.duration_ms{type="urlset"}`
*/
build(): string {
const start = Date.now();
const { pretty, xmlns } = this._options;
const nl = pretty ? "\n" : "";
// Build namespace declarations
const nsAttrs: string[] = [
`xmlns="${NS_SITEMAP}"`,
`xmlns:image="${NS_IMAGE}"`,
`xmlns:video="${NS_VIDEO}"`,
`xmlns:xhtml="${NS_XHTML}"`,
];
for (const [prefix, uri] of Object.entries(xmlns)) {
nsAttrs.push(`xmlns:${prefix}="${escapeXml(uri)}"`);
}
const nsIndent = pretty ? "\n " : " ";
const nsPart = nsAttrs.join(nsIndent);
const lines: string[] = [
`<?xml version="1.0" encoding="UTF-8"?>`,
`<urlset ${nsPart}>`,
];
for (const url of this._urls) {
lines.push(renderUrl(url, pretty));
}
lines.push(`</urlset>${nl}`);
const xml = lines.join(nl);
// OTel instrumentation
const meter = getMeter();
const attrs: Attributes = { type: "urlset" };
meter.createCounter("sitemap.build.total").add(1, attrs);
meter
.createHistogram("sitemap.build.duration_ms")
.record(Date.now() - start, attrs);
return xml;
}
}
/**
* Factory function that returns a new `SitemapBuilder` instance.
*
* @param options - Optional builder configuration.
*
* @example
* ```ts
* const sitemap = createSitemap({ pretty: true });
* sitemap.add({ loc: "https://example.com/" });
* const xml = sitemap.build();
* ```
*/
export function createSitemap(options?: SitemapBuilderOptions): SitemapBuilder {
return new SitemapBuilder(options);
}

View File

@@ -0,0 +1,117 @@
# index-builder
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the `SitemapIndexBuilder` class and `createSitemapIndex` factory for
producing `<sitemapindex>` XML documents per the sitemaps.org protocol. A
sitemap index is used when a site's content spans multiple individual sitemap
files (for example, one per content type or locale). This module handles
serialisation only; fetching or splitting URLs across child sitemaps is the
responsibility of the caller.
## Features
- Fluent `add(loc, lastmod?)` API returning `this` for chaining
- URL validation on each `add()` call — throws `INVALID_URL` for non-http(s) entries
- `count` getter reflecting the number of registered entries
- Optional pretty-print mode with two-space indentation
- OTel counter `sitemap.build.total{type="sitemapindex"}` and histogram `sitemap.build.duration_ms`
## Usage: Basic Example
```ts
import { createSitemapIndex } from "@suaveplan/sitemap/index-builder";
const xml = createSitemapIndex({ pretty: true })
.add("https://example.com/sitemap-blog.xml", "2024-06-01")
.add("https://example.com/sitemap-products.xml")
.build();
console.log(xml);
// <?xml version="1.0" encoding="UTF-8"?>
// <sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
//
// <sitemap>
// <loc>https://example.com/sitemap-blog.xml</loc>
// <lastmod>2024-06-01</lastmod>
// </sitemap>
//
// <sitemap>
// <loc>https://example.com/sitemap-products.xml</loc>
// </sitemap>
//
// </sitemapindex>
```
## Usage: Advanced Example
```ts
import { SitemapIndexBuilder } from "@suaveplan/sitemap/index-builder";
// Build an index from a database query result
interface SitemapRecord { url: string; updatedAt: Date }
function buildIndexFromRecords(records: SitemapRecord[]): string {
const builder = new SitemapIndexBuilder({ pretty: false });
for (const record of records) {
const isoDate = record.updatedAt.toISOString().split("T")[0];
builder.add(record.url, isoDate);
}
console.log(`Index contains ${builder.count} sitemaps`);
return builder.build();
}
const xml = buildIndexFromRecords([
{ url: "https://example.com/sitemap-en.xml", updatedAt: new Date("2024-01-10") },
{ url: "https://example.com/sitemap-fr.xml", updatedAt: new Date("2024-01-12") },
]);
```
## API Reference
### `SitemapIndexBuilderOptions`
| Field | Type | Default | Description |
|----------|-----------|---------|--------------------------|
| `pretty` | `boolean` | `false` | Indent XML output |
### `class SitemapIndexBuilder`
#### `constructor(options?: SitemapIndexBuilderOptions)`
Creates a new builder. All options are optional.
#### `add(loc: string, lastmod?: string): this`
Registers one sitemap entry. Throws `SitemapError(INVALID_URL)` when `loc` is
not a valid absolute `http/https` URL.
| Param | Type | Description |
|-----------|--------------------|-----------------------------------------|
| `loc` | `string` | Absolute URL of the child sitemap file |
| `lastmod` | `string` (optional) | ISO 8601 modification date |
#### `get count: number`
Returns the number of entries registered so far.
#### `build(): string`
Serialises all entries to a `<sitemapindex>` XML document. Emits OTel metrics.
### `createSitemapIndex(options?: SitemapIndexBuilderOptions): SitemapIndexBuilder`
Convenience factory equivalent to `new SitemapIndexBuilder(options)`.
## Implementation Notes
URL validation is performed eagerly in `add()` rather than lazily in `build()`
so errors surface at the point of insertion rather than at serialisation time.
This makes debugging large programmatically-generated indexes much easier. The
trade-off is a slightly higher per-call cost, which is negligible in practice
since sitemap indexes rarely exceed a few thousand entries.

View File

@@ -0,0 +1,84 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SITEMAP_CODES, SitemapError } from "../../errors/sitemap-error.js";
import { createSitemapIndex, SitemapIndexBuilder } from "./index-builder.js";
describe("SitemapIndexBuilder", () => {
it("empty index produces valid sitemapindex XML", { timeout: 5000 }, () => {
const xml = createSitemapIndex().build();
expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>');
expect(xml).toContain("<sitemapindex ");
expect(xml).toContain("</sitemapindex>");
expect(xml).toContain("http://www.sitemaps.org/schemas/sitemap/0.9");
});
it("builds index with 3 sitemaps and verifies structure", {
timeout: 5000,
}, () => {
const xml = createSitemapIndex({ pretty: true })
.add("https://example.com/sitemap-1.xml", "2024-01-01")
.add("https://example.com/sitemap-2.xml", "2024-02-01")
.add("https://example.com/sitemap-3.xml")
.build();
expect(xml).toContain("<sitemap>");
expect(xml).toContain("</sitemap>");
expect(xml).toContain("<loc>https://example.com/sitemap-1.xml</loc>");
expect(xml).toContain("<lastmod>2024-01-01</lastmod>");
expect(xml).toContain("<loc>https://example.com/sitemap-2.xml</loc>");
expect(xml).toContain("<lastmod>2024-02-01</lastmod>");
expect(xml).toContain("<loc>https://example.com/sitemap-3.xml</loc>");
// No lastmod for sitemap-3
const sitemap3Block = xml.substring(
xml.lastIndexOf("<loc>https://example.com/sitemap-3.xml</loc>")
);
expect(sitemap3Block).not.toContain("<lastmod>");
});
it("count reflects registered sitemaps", { timeout: 5000 }, () => {
const builder = createSitemapIndex();
expect(builder.count).toBe(0);
builder.add("https://example.com/s1.xml");
expect(builder.count).toBe(1);
builder.add("https://example.com/s2.xml", "2024-03-01");
expect(builder.count).toBe(2);
});
it("invalid URL throws INVALID_URL", { timeout: 5000 }, () => {
const builder = createSitemapIndex();
expect(() => builder.add("not-a-url")).toThrow(SitemapError);
expect(() => builder.add("not-a-url")).toThrow(
expect.objectContaining({ code: SITEMAP_CODES.INVALID_URL })
);
});
it("ftp URL is rejected with INVALID_URL", { timeout: 5000 }, () => {
expect(() =>
createSitemapIndex().add("ftp://example.com/sitemap.xml")
).toThrow(expect.objectContaining({ code: SITEMAP_CODES.INVALID_URL }));
});
it("pretty=false produces compact XML", { timeout: 5000 }, () => {
const xml = createSitemapIndex({ pretty: false })
.add("https://example.com/s.xml")
.build();
expect(xml).not.toContain("\n <sitemap>");
});
it("chaining add returns the same builder instance", {
timeout: 5000,
}, () => {
const builder = createSitemapIndex();
const result = builder.add("https://example.com/s.xml");
expect(result).toBe(builder);
});
it("escapes special characters in loc and lastmod", {
timeout: 5000,
}, () => {
// Unusual but possible locale-encoded URLs
const xml = new SitemapIndexBuilder({ pretty: false })
.add("https://example.com/sitemap.xml?x=1&y=2", "2024-01-01")
.build();
expect(xml).toContain("https://example.com/sitemap.xml?x=1&amp;y=2");
});
});

View File

@@ -0,0 +1,143 @@
import type { Attributes } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
import { SITEMAP_CODES, SitemapError } from "../../errors/sitemap-error.js";
import { escapeXml, isValidUrl } from "../../utils/url-utils.js";
const NS_SITEMAP = "http://www.sitemaps.org/schemas/sitemap/0.9";
/** Options for `SitemapIndexBuilder` / `createSitemapIndex`. */
export interface SitemapIndexBuilderOptions {
/**
* When `true`, the builder indents the XML output with two-space
* indentation for human readability.
* @default false
*/
pretty?: boolean;
}
interface SitemapEntry {
loc: string;
lastmod?: string;
}
// ---------------------------------------------------------------------------
// SitemapIndexBuilder
// ---------------------------------------------------------------------------
/**
* Fluent builder for sitemap index documents (`<sitemapindex>`).
*
* A sitemap index lists the locations of individual sitemap files. Per the
* sitemaps.org specification each `<loc>` must be an absolute URL.
*
* @example
* ```ts
* const xml = createSitemapIndex({ pretty: true })
* .add("https://example.com/sitemap-1.xml", "2024-01-01")
* .add("https://example.com/sitemap-2.xml")
* .build();
* ```
*/
export class SitemapIndexBuilder {
protected readonly _pretty: boolean;
protected _sitemaps: SitemapEntry[] = [];
constructor(options: SitemapIndexBuilderOptions = {}) {
this._pretty = options.pretty ?? false;
}
/**
* Adds a sitemap entry.
*
* @param loc - Absolute URL of the sitemap file.
* @param lastmod - Optional ISO 8601 last-modification date.
* @throws {SitemapError} `INVALID_URL` when `loc` is not a valid absolute
* `http/https` URL.
*/
add(loc: string, lastmod?: string): this {
if (!isValidUrl(loc)) {
throw new SitemapError(
`Invalid sitemap URL (must be absolute http/https): ${loc}`,
SITEMAP_CODES.INVALID_URL
);
}
if (lastmod !== undefined) {
this._sitemaps.push({ loc, lastmod });
} else {
this._sitemaps.push({ loc });
}
return this;
}
/** Returns the number of sitemap entries currently registered. */
get count(): number {
return this._sitemaps.length;
}
/**
* Serialises the accumulated entries to a `<sitemapindex>` XML string.
*
* Emits OTel metrics:
* - Counter `sitemap.build.total{type="sitemapindex"}`
* - Histogram `sitemap.build.duration_ms{type="sitemapindex"}`
*/
build(): string {
const start = Date.now();
const pretty = this._pretty;
const nl = pretty ? "\n" : "";
const i1 = pretty ? "\n " : "";
const i2 = pretty ? "\n " : "";
const lines: string[] = [
`<?xml version="1.0" encoding="UTF-8"?>`,
`<sitemapindex xmlns="${NS_SITEMAP}">`,
];
for (const entry of this._sitemaps) {
const parts: string[] = [
`${i1}<sitemap>`,
`${i2}<loc>${escapeXml(entry.loc)}</loc>`,
];
if (entry.lastmod) {
parts.push(
`${i2}<lastmod>${escapeXml(entry.lastmod)}</lastmod>`
);
}
parts.push(`${i1}</sitemap>`);
lines.push(parts.join(nl));
}
lines.push(`</sitemapindex>${nl}`);
const xml = lines.join(nl);
// OTel
const meter = metrics.getMeter("@suaveplan/sitemap", "0.1.0");
const attrs: Attributes = { type: "sitemapindex" };
meter.createCounter("sitemap.build.total").add(1, attrs);
meter
.createHistogram("sitemap.build.duration_ms")
.record(Date.now() - start, attrs);
return xml;
}
}
/**
* Factory function that returns a new `SitemapIndexBuilder`.
*
* @param options - Optional builder configuration.
*
* @example
* ```ts
* const xml = createSitemapIndex({ pretty: true })
* .add("https://example.com/sitemap-blog.xml", "2024-06-01")
* .build();
* ```
*/
export function createSitemapIndex(
options?: SitemapIndexBuilderOptions
): SitemapIndexBuilder {
return new SitemapIndexBuilder(options);
}

View File

@@ -0,0 +1,5 @@
export {
createSitemapIndex,
SitemapIndexBuilder,
type SitemapIndexBuilderOptions,
} from "./index-builder";

View File

@@ -0,0 +1,11 @@
export {
createSitemap,
SitemapBuilder,
type SitemapBuilderOptions,
} from "./builder";
export {
createSitemapIndex,
SitemapIndexBuilder,
type SitemapIndexBuilderOptions,
} from "./index-builder";
export { parseSitemap, parseSitemapIndex } from "./parser";

View File

@@ -0,0 +1 @@
export { parseSitemap, parseSitemapIndex } from "./sitemap-parser";

View File

@@ -0,0 +1,127 @@
# sitemap-parser
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements `parseSitemap` and `parseSitemapIndex` — the two functions that
turn raw XML strings into typed `SitemapUrl[]` and `SitemapIndex` values.
The parser uses `fast-xml-parser` under the hood to handle the sitemaps.org
base format as well as Google's image and video extensions and hreflang
alternate links. It is intentionally read-only; no writing, validation, or
normalisation of the output values is performed — callers can pipe the output
through Zod schemas if they need strict validation.
## Features
- Parses standard `<urlset>` sitemaps (`loc`, `lastmod`, `changefreq`, `priority`)
- Image extension: `image:image` entries mapped to `SitemapImage` objects
- Video extension: full `video:video` entries including tags, ratings, and flags
- hreflang alternate links via `<xhtml:link rel="alternate" …/>`
- Parses `<sitemapindex>` documents into `SitemapIndex` objects
- Structured `SitemapError(PARSE_ERROR)` on malformed XML or wrong root element
- Graceful handling of missing optional fields (returns `undefined` rather than throwing)
## Usage: Basic Example
```ts
import { parseSitemap } from "@suaveplan/sitemap/parser";
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://example.com/</loc>
<lastmod>2024-01-01</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
</urlset>`;
const urls = parseSitemap(xml);
console.log(urls[0].loc); // "https://example.com/"
console.log(urls[0].changefreq); // "weekly"
console.log(urls[0].priority); // 1
```
## Usage: Advanced Example
```ts
import { parseSitemap, parseSitemapIndex } from "@suaveplan/sitemap/parser";
// Parsing a rich sitemap with image and video extensions
const richXml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"
xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url>
<loc>https://example.com/gallery</loc>
<image:image>
<image:loc>https://cdn.example.com/photo.jpg</image:loc>
<image:caption>Sunset view</image:caption>
</image:image>
<video:video>
<video:thumbnail_loc>https://cdn.example.com/thumb.jpg</video:thumbnail_loc>
<video:title>Product tour</video:title>
<video:description>A tour of our features</video:description>
<video:duration>180</video:duration>
<video:family_friendly>yes</video:family_friendly>
<video:tag>features</video:tag>
<video:tag>tour</video:tag>
</video:video>
</url>
</urlset>`;
const urls = parseSitemap(richXml);
console.log(urls[0].images?.[0].caption); // "Sunset view"
console.log(urls[0].videos?.[0].duration); // 180
console.log(urls[0].videos?.[0].tags); // ["features", "tour"]
// Parsing a sitemap index
const indexXml = `<?xml version="1.0"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap><loc>https://example.com/sitemap-1.xml</loc></sitemap>
</sitemapindex>`;
const index = parseSitemapIndex(indexXml);
console.log(index.sitemaps[0].loc); // "https://example.com/sitemap-1.xml"
```
## API Reference
### `parseSitemap(xml: string): SitemapUrl[]`
Parses a `<urlset>` sitemap XML string.
| Param | Type | Description |
|-------|----------|-------------------------|
| `xml` | `string` | Raw sitemap XML content |
**Returns:** `SitemapUrl[]` — may be empty for a sitemap with no `<url>` elements.
**Throws:** `SitemapError(PARSE_ERROR)` when XML is malformed or missing the `<urlset>` root.
### `parseSitemapIndex(xml: string): SitemapIndex`
Parses a `<sitemapindex>` document.
| Param | Type | Description |
|-------|----------|------------------------------|
| `xml` | `string` | Raw sitemap index XML content |
**Returns:** `SitemapIndex` with a `sitemaps` array (may be empty).
**Throws:** `SitemapError(PARSE_ERROR)` when XML is malformed or missing the `<sitemapindex>` root.
## Implementation Notes
`fast-xml-parser` is configured with `isArray` callbacks for `url`, `sitemap`,
`image:image`, `video:video`, `xhtml:link`, and `video:tag` so that single-element
collections are still returned as arrays. Without this, a sitemap with exactly
one URL would return an object rather than a one-element array, causing the
consumer to crash.
Boolean fields in video sitemaps (`family_friendly`, `requires_subscription`,
`live`) use "yes"/"no" string encoding per the sitemaps.org spec. The
`getBoolean` helper handles both "yes/no" and "true/false" forms for tolerance
of non-conformant producers.

View File

@@ -0,0 +1,418 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { SITEMAP_CODES, SitemapError } from "../../errors/sitemap-error.js";
import { parseSitemap, parseSitemapIndex } from "./sitemap-parser.js";
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const BASIC_SITEMAP = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://example.com/</loc>
<lastmod>2024-01-01</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://example.com/about</loc>
<changefreq>monthly</changefreq>
</url>
</urlset>`;
const IMAGE_SITEMAP = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
<url>
<loc>https://example.com/gallery</loc>
<image:image>
<image:loc>https://cdn.example.com/photo.jpg</image:loc>
<image:caption>A nice photo</image:caption>
<image:title>Photo Title</image:title>
<image:geo_location>Paris, FR</image:geo_location>
<image:license>https://creativecommons.org/licenses/by/4.0/</image:license>
</image:image>
</url>
</urlset>`;
const VIDEO_SITEMAP = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url>
<loc>https://example.com/videos/intro</loc>
<video:video>
<video:thumbnail_loc>https://cdn.example.com/thumb.jpg</video:thumbnail_loc>
<video:title>Intro Video</video:title>
<video:description>An introduction to our product</video:description>
<video:duration>120</video:duration>
<video:rating>4.8</video:rating>
<video:view_count>5000</video:view_count>
<video:family_friendly>yes</video:family_friendly>
<video:requires_subscription>no</video:requires_subscription>
<video:live>no</video:live>
<video:tag>intro</video:tag>
<video:tag>product</video:tag>
</video:video>
</url>
</urlset>`;
const HREFLANG_SITEMAP = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url>
<loc>https://example.com/en/home</loc>
<xhtml:link rel="alternate" hreflang="fr" href="https://example.com/fr/home"/>
<xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/"/>
</url>
</urlset>`;
const SITEMAP_INDEX = `<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap>
<loc>https://example.com/sitemap-1.xml</loc>
<lastmod>2024-01-01</lastmod>
</sitemap>
<sitemap>
<loc>https://example.com/sitemap-2.xml</loc>
</sitemap>
</sitemapindex>`;
const EMPTY_URLSET = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></urlset>`;
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("parseSitemap", () => {
it("parses a basic sitemap with standard fields", { timeout: 5000 }, () => {
const urls = parseSitemap(BASIC_SITEMAP);
expect(urls).toHaveLength(2);
const first = urls[0];
expect(first.loc).toBe("https://example.com/");
expect(first.lastmod).toBe("2024-01-01");
expect(first.changefreq).toBe("weekly");
expect(first.priority).toBe(1);
});
it("parses a URL without optional fields", { timeout: 5000 }, () => {
const urls = parseSitemap(BASIC_SITEMAP);
const second = urls[1];
expect(second.loc).toBe("https://example.com/about");
expect(second.changefreq).toBe("monthly");
expect(second.lastmod).toBeUndefined();
expect(second.priority).toBeUndefined();
});
it("parses image extension data", { timeout: 5000 }, () => {
const urls = parseSitemap(IMAGE_SITEMAP);
expect(urls).toHaveLength(1);
const images = urls[0].images;
expect(images).toHaveLength(1);
expect(images?.[0].loc).toBe("https://cdn.example.com/photo.jpg");
expect(images?.[0].caption).toBe("A nice photo");
expect(images?.[0].title).toBe("Photo Title");
expect(images?.[0].geoLocation).toBe("Paris, FR");
expect(images?.[0].license).toBe(
"https://creativecommons.org/licenses/by/4.0/"
);
});
it("parses video extension data", { timeout: 5000 }, () => {
const urls = parseSitemap(VIDEO_SITEMAP);
expect(urls).toHaveLength(1);
const videos = urls[0].videos;
expect(videos).toHaveLength(1);
const v = videos?.[0];
expect(v?.thumbnailLoc).toBe("https://cdn.example.com/thumb.jpg");
expect(v?.title).toBe("Intro Video");
expect(v?.description).toBe("An introduction to our product");
expect(v?.duration).toBe(120);
expect(v?.rating).toBe(4.8);
expect(v?.viewCount).toBe(5000);
expect(v?.familyFriendly).toBe(true);
expect(v?.requiresSubscription).toBe(false);
expect(v?.live).toBe(false);
expect(v?.tags).toEqual(["intro", "product"]);
});
it("parses hreflang alternate links", { timeout: 5000 }, () => {
const urls = parseSitemap(HREFLANG_SITEMAP);
expect(urls).toHaveLength(1);
const alts = urls[0].alternates;
expect(alts).toHaveLength(2);
expect(alts?.[0].hreflang).toBe("fr");
expect(alts?.[0].href).toBe("https://example.com/fr/home");
expect(alts?.[1].hreflang).toBe("x-default");
});
it("returns empty array for a sitemap with no URL entries", {
timeout: 5000,
}, () => {
const urls = parseSitemap(EMPTY_URLSET);
expect(urls).toEqual([]);
});
it("throws PARSE_ERROR for malformed XML", { timeout: 5000 }, () => {
expect(() => parseSitemap("<not valid xml <<<<")).toThrow(SitemapError);
expect(() => parseSitemap("<not valid xml <<<<")).toThrow(
expect.objectContaining({ code: SITEMAP_CODES.PARSE_ERROR })
);
});
it("throws PARSE_ERROR when root is not urlset", { timeout: 5000 }, () => {
const wrongRoot = `<?xml version="1.0"?><sitemapindex></sitemapindex>`;
expect(() => parseSitemap(wrongRoot)).toThrow(
expect.objectContaining({ code: SITEMAP_CODES.PARSE_ERROR })
);
});
it("handles URL entry with missing loc tag gracefully", {
timeout: 5000,
}, () => {
// Covers the `?? ""` fallback when getString returns undefined for missing loc
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<changefreq>weekly</changefreq>
</url>
</urlset>`;
const urls = parseSitemap(xml);
expect(urls[0].loc).toBe("");
});
it("parses numeric lastmod value (fast-xml-parser parseTagValue coercion)", {
timeout: 5000,
}, () => {
// fast-xml-parser with parseTagValue:true coerces "2024" to number 2024
// getString must handle numeric values from the parser
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://example.com/numeric-date</loc>
<lastmod>2024</lastmod>
</url>
</urlset>`;
const urls = parseSitemap(xml);
expect(urls[0].loc).toBe("https://example.com/numeric-date");
// lastmod "2024" is coerced to number 2024 by fast-xml-parser
// getString converts it back to "2024"
expect(urls[0].lastmod).toBe("2024");
});
it("handles video with unknown boolean value as undefined", {
timeout: 5000,
}, () => {
// family_friendly set to an unexpected value - getBoolean should return undefined
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url>
<loc>https://example.com/v</loc>
<video:video>
<video:thumbnail_loc>https://example.com/t.jpg</video:thumbnail_loc>
<video:title>Test</video:title>
<video:description>Test video</video:description>
<video:family_friendly>maybe</video:family_friendly>
</video:video>
</url>
</urlset>`;
const urls = parseSitemap(xml);
expect(urls[0].videos?.[0].familyFriendly).toBeUndefined();
});
it("handles video entry with missing required fields (thumbnailLoc/title/description)", {
timeout: 5000,
}, () => {
// Covers the `?? ""` fallbacks for required video fields when absent
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url>
<loc>https://example.com/incomplete-video</loc>
<video:video>
<video:duration>60</video:duration>
</video:video>
</url>
</urlset>`;
const urls = parseSitemap(xml);
expect(urls[0].videos?.[0].thumbnailLoc).toBe("");
expect(urls[0].videos?.[0].title).toBe("");
expect(urls[0].videos?.[0].description).toBe("");
});
it("handles boolean 'true'/'false' string values in video fields", {
timeout: 5000,
}, () => {
// Covers the s === "true" and s === "false" branches in getBoolean
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url>
<loc>https://example.com/v3</loc>
<video:video>
<video:thumbnail_loc>https://example.com/t.jpg</video:thumbnail_loc>
<video:title>Bool Test</video:title>
<video:description>Testing boolean coercion</video:description>
<video:requires_subscription>true</video:requires_subscription>
<video:live>false</video:live>
</video:video>
</url>
</urlset>`;
const urls = parseSitemap(xml);
expect(urls[0].videos?.[0].requiresSubscription).toBe(true);
expect(urls[0].videos?.[0].live).toBe(false);
});
it("handles video with non-numeric duration (covers Number(val) and non-finite branch)", {
timeout: 5000,
}, () => {
// fast-xml-parser with parseTagValue:true leaves non-numeric strings as strings
// This exercises: (a) the Number(val) branch of getNumber (when typeof val !== "number")
// (b) the !Number.isFinite path returning undefined
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url>
<loc>https://example.com/v-nan</loc>
<video:video>
<video:thumbnail_loc>https://example.com/t.jpg</video:thumbnail_loc>
<video:title>NaN Test</video:title>
<video:description>Duration is non-numeric</video:description>
<video:duration>not-a-number</video:duration>
</video:video>
</url>
</urlset>`;
const urls = parseSitemap(xml);
// non-numeric string → Number("not-a-number") = NaN → not finite → undefined
expect(urls[0].videos?.[0].duration).toBeUndefined();
});
it("handles image with missing loc element (covers ?? '' fallback in parseImages)", {
timeout: 5000,
}, () => {
// An image:image element with no image:loc child exercises getString returning
// undefined and the ?? "" fallback on line 62
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
<url>
<loc>https://example.com/img-no-loc</loc>
<image:image>
<image:caption>No loc provided</image:caption>
</image:image>
</url>
</urlset>`;
const urls = parseSitemap(xml);
expect(urls[0].images?.[0].loc).toBe("");
expect(urls[0].images?.[0].caption).toBe("No loc provided");
});
it("handles alternate links with non-alternate rel as ignored", {
timeout: 5000,
}, () => {
// xhtml:link with rel != "alternate" should be filtered out
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url>
<loc>https://example.com/</loc>
<xhtml:link rel="canonical" hreflang="en" href="https://example.com/"/>
<xhtml:link rel="alternate" hreflang="fr" href="https://example.com/fr/"/>
</url>
</urlset>`;
const urls = parseSitemap(xml);
// Only the "alternate" link should be included
expect(urls[0].alternates).toHaveLength(1);
expect(urls[0].alternates?.[0].hreflang).toBe("fr");
});
it("handles video with a single string tag (not array)", {
timeout: 5000,
}, () => {
// When a video has exactly one tag, fast-xml-parser may not return an array
// even with isArray config — test the single-value code path by constructing
// a sitemap where parseVideos receives a non-array tag value
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
<url>
<loc>https://example.com/v2</loc>
<video:video>
<video:thumbnail_loc>https://example.com/t.jpg</video:thumbnail_loc>
<video:title>One Tag</video:title>
<video:description>Has one tag</video:description>
<video:tag>single</video:tag>
</video:video>
</url>
</urlset>`;
const urls = parseSitemap(xml);
// With isArray configured for video:tag, this should still return as array
expect(urls[0].videos?.[0].tags).toEqual(["single"]);
});
});
describe("parseSitemapIndex", () => {
it("parses a sitemap index with 2 entries", { timeout: 5000 }, () => {
const index = parseSitemapIndex(SITEMAP_INDEX);
expect(index.sitemaps).toHaveLength(2);
});
it("verifies first sitemap entry has loc and lastmod", {
timeout: 5000,
}, () => {
const index = parseSitemapIndex(SITEMAP_INDEX);
expect(index.sitemaps[0].loc).toBe("https://example.com/sitemap-1.xml");
expect(index.sitemaps[0].lastmod).toBe("2024-01-01");
});
it("verifies second sitemap entry has loc but no lastmod", {
timeout: 5000,
}, () => {
const index = parseSitemapIndex(SITEMAP_INDEX);
expect(index.sitemaps[1].loc).toBe("https://example.com/sitemap-2.xml");
expect(index.sitemaps[1].lastmod).toBeUndefined();
});
it("returns empty sitemaps array for index with no entries", {
timeout: 5000,
}, () => {
const emptyIndex = `<?xml version="1.0"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"></sitemapindex>`;
const index = parseSitemapIndex(emptyIndex);
expect(index.sitemaps).toEqual([]);
});
it("throws PARSE_ERROR for malformed XML", { timeout: 5000 }, () => {
expect(() => parseSitemapIndex("<broken<xml")).toThrow(SitemapError);
expect(() => parseSitemapIndex("<broken<xml")).toThrow(
expect.objectContaining({ code: SITEMAP_CODES.PARSE_ERROR })
);
});
it("throws PARSE_ERROR when root is not sitemapindex", {
timeout: 5000,
}, () => {
const wrongRoot = `<?xml version="1.0"?><urlset></urlset>`;
expect(() => parseSitemapIndex(wrongRoot)).toThrow(
expect.objectContaining({ code: SITEMAP_CODES.PARSE_ERROR })
);
});
it("handles sitemap entry with missing loc tag gracefully", {
timeout: 5000,
}, () => {
// Covers the `?? ""` fallback when getString returns undefined for missing loc
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap>
<lastmod>2024-01-01</lastmod>
</sitemap>
</sitemapindex>`;
const index = parseSitemapIndex(xml);
expect(index.sitemaps[0].loc).toBe("");
expect(index.sitemaps[0].lastmod).toBe("2024-01-01");
});
});

View File

@@ -0,0 +1,263 @@
import { XMLParser } from "fast-xml-parser";
import { SITEMAP_CODES, SitemapError } from "../../errors/sitemap-error.js";
import type {
SitemapAlternate,
SitemapImage,
SitemapIndex,
SitemapUrl,
SitemapVideo,
} from "../../schemas/sitemap.schema.js";
// ---------------------------------------------------------------------------
// XML parser configuration
// ---------------------------------------------------------------------------
const PARSER_OPTIONS = {
ignoreAttributes: false,
attributeNamePrefix: "@_",
isArray: (name: string) =>
[
"url",
"sitemap",
"image:image",
"video:video",
"xhtml:link",
"video:tag",
].includes(name),
parseTagValue: true,
trimValues: true,
} as const;
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
function getString(val: unknown): string | undefined {
if (val === undefined || val === null) return undefined;
// fast-xml-parser with parseTagValue:true may coerce text to number/boolean
if (typeof val === "string") return val;
return String(val);
}
function getNumber(val: unknown): number | undefined {
if (val === undefined || val === null) return undefined;
const n = typeof val === "number" ? val : Number(val);
return Number.isFinite(n) ? n : undefined;
}
function getBoolean(val: unknown): boolean | undefined {
if (val === undefined || val === null) return undefined;
if (typeof val === "boolean") return val;
const s = String(val).toLowerCase().trim();
if (s === "yes" || s === "true") return true;
if (s === "no" || s === "false") return false;
return undefined;
}
function parseImages(raw: unknown): SitemapImage[] {
if (!Array.isArray(raw)) return [];
return (raw as unknown[]).map((img) => {
const o = img as Record<string, unknown>;
return {
loc: getString(o["image:loc"]) ?? "",
caption: getString(o["image:caption"]),
title: getString(o["image:title"]),
geoLocation: getString(o["image:geo_location"]),
license: getString(o["image:license"]),
} satisfies SitemapImage;
});
}
function parseVideos(raw: unknown): SitemapVideo[] {
if (!Array.isArray(raw)) return [];
return (raw as unknown[]).map((vid) => {
const o = vid as Record<string, unknown>;
const tags = o["video:tag"];
let parsedTags: string[] | undefined;
// isArray config guarantees video:tag is always an array
if (Array.isArray(tags)) {
parsedTags = (tags as unknown[])
.map((t) => String(t))
.filter(Boolean);
}
return {
thumbnailLoc: getString(o["video:thumbnail_loc"]) ?? "",
title: getString(o["video:title"]) ?? "",
description: getString(o["video:description"]) ?? "",
contentLoc: getString(o["video:content_loc"]),
playerLoc: getString(o["video:player_loc"]),
duration: getNumber(o["video:duration"]),
publicationDate: getString(o["video:publication_date"]),
expirationDate: getString(o["video:expiration_date"]),
rating: getNumber(o["video:rating"]),
viewCount: getNumber(o["video:view_count"]),
familyFriendly: getBoolean(o["video:family_friendly"]),
requiresSubscription: getBoolean(o["video:requires_subscription"]),
live: getBoolean(o["video:live"]),
tags: parsedTags,
} satisfies SitemapVideo;
});
}
function parseAlternates(raw: unknown): SitemapAlternate[] {
if (!Array.isArray(raw)) return [];
return (raw as unknown[]).flatMap((link) => {
const o = link as Record<string, unknown>;
const rel = getString(o["@_rel"]);
const hreflang = getString(o["@_hreflang"]);
const href = getString(o["@_href"]);
if (rel === "alternate" && hreflang && href) {
return [{ hreflang, href } satisfies SitemapAlternate];
}
return [];
});
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Parses a sitemap.xml string into an array of `SitemapUrl` objects.
*
* Handles the standard fields (`loc`, `lastmod`, `changefreq`, `priority`)
* as well as the Google image, video, and hreflang extensions.
*
* @param xml - Raw sitemap XML string.
* @returns Array of parsed URL entries.
* @throws {SitemapError} `PARSE_ERROR` when the XML cannot be parsed or does
* not contain a `<urlset>` root element.
*
* @example
* ```ts
* import { parseSitemap } from "@suaveplan/sitemap/parser";
*
* const urls = parseSitemap(`<?xml version="1.0"?>
* <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
* <url><loc>https://example.com/</loc></url>
* </urlset>`);
*
* console.log(urls[0].loc); // "https://example.com/"
* ```
*/
export function parseSitemap(xml: string): SitemapUrl[] {
let parsed: Record<string, unknown>;
try {
const parser = new XMLParser(PARSER_OPTIONS);
parsed = parser.parse(xml) as Record<string, unknown>;
} catch (err) {
const msg =
err instanceof Error
? err.message
: /* istanbul ignore next */ String(err);
throw new SitemapError(
`Failed to parse sitemap XML: ${msg}`,
SITEMAP_CODES.PARSE_ERROR
);
}
const urlset = parsed["urlset"] as Record<string, unknown> | undefined;
if (!urlset) {
throw new SitemapError(
"Parsed XML does not contain a <urlset> root element",
SITEMAP_CODES.PARSE_ERROR
);
}
const rawUrls = urlset["url"];
if (!Array.isArray(rawUrls)) {
// Empty sitemap with no <url> entries
return [];
}
return (rawUrls as unknown[]).map((raw) => {
const u = raw as Record<string, unknown>;
const loc = getString(u["loc"]) ?? "";
const lastmod = getString(u["lastmod"]);
const changefreq = getString(
u["changefreq"]
) as SitemapUrl["changefreq"];
const priorityRaw = getNumber(u["priority"]);
const images = parseImages(u["image:image"]);
const videos = parseVideos(u["video:video"]);
const alternates = parseAlternates(u["xhtml:link"]);
const entry: SitemapUrl = { loc };
if (lastmod) entry.lastmod = lastmod;
if (changefreq) entry.changefreq = changefreq;
if (priorityRaw !== undefined) entry.priority = priorityRaw;
if (images.length > 0) entry.images = images;
if (videos.length > 0) entry.videos = videos;
if (alternates.length > 0) entry.alternates = alternates;
return entry;
});
}
/**
* Parses a sitemap index XML string into a `SitemapIndex` object.
*
* @param xml - Raw sitemapindex XML string.
* @returns A `SitemapIndex` with a `sitemaps` array.
* @throws {SitemapError} `PARSE_ERROR` when the XML cannot be parsed or does
* not contain a `<sitemapindex>` root element.
*
* @example
* ```ts
* import { parseSitemapIndex } from "@suaveplan/sitemap/parser";
*
* const index = parseSitemapIndex(`<?xml version="1.0"?>
* <sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
* <sitemap>
* <loc>https://example.com/sitemap-1.xml</loc>
* <lastmod>2024-01-01</lastmod>
* </sitemap>
* </sitemapindex>`);
*
* console.log(index.sitemaps[0].loc); // "https://example.com/sitemap-1.xml"
* ```
*/
export function parseSitemapIndex(xml: string): SitemapIndex {
let parsed: Record<string, unknown>;
try {
const parser = new XMLParser(PARSER_OPTIONS);
parsed = parser.parse(xml) as Record<string, unknown>;
} catch (err) {
const msg =
err instanceof Error
? err.message
: /* istanbul ignore next */ String(err);
throw new SitemapError(
`Failed to parse sitemap index XML: ${msg}`,
SITEMAP_CODES.PARSE_ERROR
);
}
const index = parsed["sitemapindex"] as Record<string, unknown> | undefined;
if (!index) {
throw new SitemapError(
"Parsed XML does not contain a <sitemapindex> root element",
SITEMAP_CODES.PARSE_ERROR
);
}
const rawSitemaps = index["sitemap"];
if (!Array.isArray(rawSitemaps)) {
return { sitemaps: [] };
}
const sitemaps = (rawSitemaps as unknown[]).map((raw) => {
const s = raw as Record<string, unknown>;
const loc = getString(s["loc"]) ?? "";
const lastmod = getString(s["lastmod"]);
const entry: { loc: string; lastmod?: string } = { loc };
if (lastmod) entry.lastmod = lastmod;
return entry;
});
return { sitemaps };
}

View File

@@ -0,0 +1 @@
export { SITEMAP_CODES, type SitemapCode, SitemapError } from "./sitemap-error";

View File

@@ -0,0 +1,103 @@
# sitemap-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the stable error-code registry and typed `SitemapError` class for the
`@suaveplan/sitemap` package. Because `@suaveplan/sitemap` is a leaf package
(no `@suaveplan/error` dependency), this module re-implements a minimal
error substrate locally — keeping the package's dependency footprint to
`fast-xml-parser`, `zod`, `tslib`, and `@opentelemetry/api`.
## Features
- `SITEMAP_CODES` — four stable codes: `INVALID_URL`, `PARSE_ERROR`,
`SIZE_EXCEEDED`, `INVALID_OPTIONS`
- `SitemapCode` — union type inferred from the code registry
- `SitemapError` — extends `Error`, exposes a narrowly-typed `code` property,
restores the prototype chain in transpiled output
## Usage: Basic Example
```ts
import { SitemapError, SITEMAP_CODES } from "@suaveplan/sitemap/errors";
try {
throw new SitemapError(
"Sitemap exceeds 50 000 URL limit",
SITEMAP_CODES.SIZE_EXCEEDED,
);
} catch (err) {
if (err instanceof SitemapError) {
console.log(err.code); // "SITEMAP.SIZE_EXCEEDED"
console.log(err.name); // "SitemapError"
}
}
```
## Usage: Advanced Example
```ts
import { SitemapError, SITEMAP_CODES, type SitemapCode } from "@suaveplan/sitemap/errors";
function assertSitemapError(err: unknown, code: SitemapCode): asserts err is SitemapError {
if (!(err instanceof SitemapError) || err.code !== code) {
throw new TypeError(`Expected SitemapError with code ${code}`);
}
}
function processUrl(url: string): void {
if (!url.startsWith("http://") && !url.startsWith("https://")) {
throw new SitemapError(
`URL must use http(s) scheme: ${url}`,
SITEMAP_CODES.INVALID_URL,
);
}
}
try {
processUrl("ftp://example.com/");
} catch (err) {
assertSitemapError(err, SITEMAP_CODES.INVALID_URL);
console.error(err.message); // "URL must use http(s) scheme: ftp://example.com/"
}
```
## API Reference
### `SITEMAP_CODES`
`Record<string, string>` — const object mapping logical names to stable code strings.
| Key | Value |
|-------------------|--------------------------|
| `INVALID_URL` | `"SITEMAP.INVALID_URL"` |
| `PARSE_ERROR` | `"SITEMAP.PARSE_ERROR"` |
| `SIZE_EXCEEDED` | `"SITEMAP.SIZE_EXCEEDED"`|
| `INVALID_OPTIONS` | `"SITEMAP.INVALID_OPTIONS"` |
### `SitemapCode`
`type SitemapCode = "SITEMAP.INVALID_URL" | "SITEMAP.PARSE_ERROR" | "SITEMAP.SIZE_EXCEEDED" | "SITEMAP.INVALID_OPTIONS"`
### `SitemapError`
`class SitemapError extends Error`
**Constructor:** `new SitemapError(message: string, code: SitemapCode)`
| Param | Type | Description |
|-----------|---------------|-------------------------|
| `message` | `string` | Human-readable message |
| `code` | `SitemapCode` | Stable machine-readable code |
**Properties:**
- `code: SitemapCode` — the stable code passed at construction
- `name: string` — always `"SitemapError"`
## Implementation Notes
The prototype chain is explicitly restored with `Object.setPrototypeOf(this, new.target.prototype)` to ensure `instanceof` checks work correctly when the package is compiled to CommonJS or older ES targets. This is the standard pattern for extending built-in classes in TypeScript without `@suaveplan/error`'s base class.

View File

@@ -0,0 +1,39 @@
/**
* Stable error-code registry for `@suaveplan/sitemap`.
*
* Each code is a dot-delimited string under the `SITEMAP` namespace.
*/
export const SITEMAP_CODES = {
INVALID_URL: "SITEMAP.INVALID_URL",
PARSE_ERROR: "SITEMAP.PARSE_ERROR",
SIZE_EXCEEDED: "SITEMAP.SIZE_EXCEEDED",
INVALID_OPTIONS: "SITEMAP.INVALID_OPTIONS",
} as const;
export type SitemapCode = (typeof SITEMAP_CODES)[keyof typeof SITEMAP_CODES];
/**
* Typed error for all `@suaveplan/sitemap` failures.
*
* `@suaveplan/sitemap` is a leaf package with no `@suaveplan/error` dependency,
* so this class extends the built-in `Error` directly and exposes a stable
* `code` property drawn from `SITEMAP_CODES`.
*
* @example
* ```ts
* import { SitemapError, SITEMAP_CODES } from "@suaveplan/sitemap/errors";
*
* throw new SitemapError("URL must be absolute", SITEMAP_CODES.INVALID_URL);
* ```
*/
export class SitemapError extends Error {
readonly code: SitemapCode;
constructor(message: string, code: SitemapCode) {
super(message);
this.name = "SitemapError";
this.code = code;
// Restore prototype chain in transpiled output
Object.setPrototypeOf(this, new.target.prototype);
}
}

View File

@@ -0,0 +1,46 @@
// Core builder
export type { SitemapBuilderOptions } from "./core/builder/sitemap-builder.js";
export {
createSitemap,
SitemapBuilder,
} from "./core/builder/sitemap-builder.js";
export type { SitemapIndexBuilderOptions } from "./core/index-builder/index-builder.js";
// Index builder
export {
createSitemapIndex,
SitemapIndexBuilder,
} from "./core/index-builder/index-builder.js";
// Parser
export {
parseSitemap,
parseSitemapIndex,
} from "./core/parser/sitemap-parser.js";
export type { SitemapCode } from "./errors/sitemap-error.js";
// Errors
export { SITEMAP_CODES, SitemapError } from "./errors/sitemap-error.js";
export type {
ChangeFrequency,
SitemapAlternate,
SitemapImage,
SitemapIndex,
SitemapUrl,
SitemapVideo,
} from "./schemas/sitemap.schema.js";
// Schemas and types
export {
ChangeFrequencySchema,
SitemapAlternateSchema,
SitemapImageSchema,
SitemapIndexSchema,
SitemapUrlSchema,
SitemapVideoSchema,
} from "./schemas/sitemap.schema.js";
// Utilities
export {
escapeXml,
formatPriority,
isValidUrl,
normalizeUrl,
} from "./utils/url-utils.js";

View File

@@ -0,0 +1,14 @@
export {
type ChangeFrequency,
ChangeFrequencySchema,
type SitemapAlternate,
SitemapAlternateSchema,
type SitemapImage,
SitemapImageSchema,
type SitemapIndex,
SitemapIndexSchema,
type SitemapUrl,
SitemapUrlSchema,
type SitemapVideo,
SitemapVideoSchema,
} from "./sitemap.schema";

View File

@@ -0,0 +1,113 @@
# sitemap.schema
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines Zod schemas for every wire-format type in `@suaveplan/sitemap`,
providing runtime validation, static type inference, and a single source of
truth for the shape of sitemap data structures. All schemas align with the
sitemaps.org protocol and Google's image/video extensions.
## Features
- `ChangeFrequencySchema` — validates the seven allowed change-frequency values
- `SitemapImageSchema` — image extension entry with optional caption, title, license, and geo-location
- `SitemapVideoSchema` — full video extension with duration, ratings, tags, and playback metadata
- `SitemapAlternateSchema` — hreflang alternate link pair
- `SitemapUrlSchema` — complete URL entry composing all extensions
- `SitemapIndexSchema` — sitemap index document with `loc` + optional `lastmod` entries
- Inferred TypeScript types exported for each schema
## Usage: Basic Example
```ts
import {
SitemapUrlSchema,
type SitemapUrl,
} from "@suaveplan/sitemap";
const raw = {
loc: "https://example.com/page",
changefreq: "weekly",
priority: 0.8,
};
const url: SitemapUrl = SitemapUrlSchema.parse(raw);
console.log(url.loc); // "https://example.com/page"
```
## Usage: Advanced Example
```ts
import {
SitemapUrlSchema,
SitemapImageSchema,
SitemapVideoSchema,
} from "@suaveplan/sitemap";
const richUrl = SitemapUrlSchema.parse({
loc: "https://example.com/media",
lastmod: "2024-06-01",
changefreq: "daily",
priority: 0.9,
images: [
SitemapImageSchema.parse({
loc: "https://cdn.example.com/hero.jpg",
caption: "Hero image",
title: "Main hero",
}),
],
videos: [
SitemapVideoSchema.parse({
thumbnailLoc: "https://cdn.example.com/thumb.jpg",
title: "Product demo",
description: "A walkthrough of key features",
duration: 180,
rating: 4.5,
familyFriendly: true,
tags: ["demo", "features"],
}),
],
});
```
## API Reference
### `ChangeFrequencySchema`
`z.ZodEnum` — one of `"always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never"`.
### `SitemapImageSchema`
Object schema with fields: `loc` (required), `caption`, `title`, `license`, `geoLocation` (all optional strings).
### `SitemapVideoSchema`
Object schema with required fields `thumbnailLoc`, `title`, `description`; optional fields include `contentLoc`, `playerLoc`, `duration` (integer ≥ 0), `publicationDate`, `expirationDate`, `rating` (0–5), `viewCount`, `familyFriendly`, `requiresSubscription`, `live`, `tags`.
### `SitemapAlternateSchema`
Object schema with required `hreflang` and `href` string fields.
### `SitemapUrlSchema`
Object schema with required `loc`; optional `lastmod`, `changefreq`, `priority` (0–1), `images`, `videos`, `alternates`.
### `SitemapIndexSchema`
Object schema with `sitemaps` array, each entry having a required `loc` and optional `lastmod`.
### Inferred Types
- `ChangeFrequency` — union of the seven frequency strings
- `SitemapImage` — inferred from `SitemapImageSchema`
- `SitemapVideo` — inferred from `SitemapVideoSchema`
- `SitemapAlternate` — inferred from `SitemapAlternateSchema`
- `SitemapUrl` — inferred from `SitemapUrlSchema`
- `SitemapIndex` — inferred from `SitemapIndexSchema`
## Implementation Notes
All schemas use `.min(1)` on required string fields to prevent empty-string entries from passing validation silently. Numeric ranges (`priority: 0–1`, `rating: 0–5`, `duration ≥ 0`, `viewCount ≥ 0`) are enforced at the schema level rather than in business logic, keeping validation rules co-located with type definitions. The schemas intentionally do not validate URL format — that responsibility lies with `url-utils.ts` / `SitemapBuilder`.

View File

@@ -0,0 +1,90 @@
import z from "zod";
/**
* Allowed change-frequency values per the sitemaps.org protocol.
*/
export const ChangeFrequencySchema = z.enum([
"always",
"hourly",
"daily",
"weekly",
"monthly",
"yearly",
"never",
]);
export type ChangeFrequency = z.infer<typeof ChangeFrequencySchema>;
/**
* An image extension entry within a sitemap URL.
*/
export const SitemapImageSchema = z.object({
loc: z.string().min(1),
caption: z.string().optional(),
title: z.string().optional(),
license: z.string().optional(),
geoLocation: z.string().optional(),
});
export type SitemapImage = z.infer<typeof SitemapImageSchema>;
/**
* A video extension entry within a sitemap URL.
*/
export const SitemapVideoSchema = z.object({
thumbnailLoc: z.string().min(1),
title: z.string().min(1),
description: z.string().min(1),
contentLoc: z.string().optional(),
playerLoc: z.string().optional(),
duration: z.number().int().nonnegative().optional(),
publicationDate: z.string().optional(),
expirationDate: z.string().optional(),
rating: z.number().min(0).max(5).optional(),
viewCount: z.number().int().nonnegative().optional(),
familyFriendly: z.boolean().optional(),
requiresSubscription: z.boolean().optional(),
live: z.boolean().optional(),
tags: z.array(z.string()).optional(),
});
export type SitemapVideo = z.infer<typeof SitemapVideoSchema>;
/**
* An hreflang alternate link annotation within a sitemap URL.
*/
export const SitemapAlternateSchema = z.object({
hreflang: z.string().min(1),
href: z.string().min(1),
});
export type SitemapAlternate = z.infer<typeof SitemapAlternateSchema>;
/**
* A single URL entry in a standard sitemap.
*/
export const SitemapUrlSchema = z.object({
loc: z.string().min(1),
lastmod: z.string().optional(),
changefreq: ChangeFrequencySchema.optional(),
priority: z.number().min(0).max(1).optional(),
images: z.array(SitemapImageSchema).optional(),
videos: z.array(SitemapVideoSchema).optional(),
alternates: z.array(SitemapAlternateSchema).optional(),
});
export type SitemapUrl = z.infer<typeof SitemapUrlSchema>;
/**
* A sitemap index file referencing multiple child sitemaps.
*/
export const SitemapIndexSchema = z.object({
sitemaps: z.array(
z.object({
loc: z.string().min(1),
lastmod: z.string().optional(),
})
),
});
export type SitemapIndex = z.infer<typeof SitemapIndexSchema>;

View File

@@ -0,0 +1,6 @@
export {
escapeXml,
formatPriority,
isValidUrl,
normalizeUrl,
} from "./url-utils";

View File

@@ -0,0 +1,97 @@
# url-utils
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides the four foundational URL and XML utility functions that the sitemap
builder and parser modules rely on. Keeping these helpers in a dedicated module
allows them to be independently tested and reused across all sitemap generation
paths without coupling them to any particular class or algorithm.
## Features
- `isValidUrl` — validates that a string is an absolute `http` or `https` URL using the platform `URL` constructor
- `normalizeUrl` — lowercases the scheme and host of any absolute URL for canonical sitemap output
- `escapeXml` — escapes the five XML-special characters (`&`, `<`, `>`, `"`, `'`)
- `formatPriority` — clamps a floating-point value to `[0.0, 1.0]` and renders it as a two-decimal-place string
## Usage: Basic Example
```ts
import {
isValidUrl,
escapeXml,
formatPriority,
} from "@suaveplan/sitemap";
console.log(isValidUrl("https://example.com/")); // true
console.log(isValidUrl("ftp://example.com/")); // false
console.log(escapeXml("a & b < c")); // "a &amp; b &lt; c"
console.log(formatPriority(0.75)); // "0.75"
```
## Usage: Advanced Example
```ts
import { isValidUrl, normalizeUrl, escapeXml, formatPriority } from "@suaveplan/sitemap";
// Normalise user-supplied URLs before XML serialisation
const userUrl = "HTTPS://Example.COM/About";
if (isValidUrl(userUrl)) {
const canonical = normalizeUrl(userUrl);
// "https://example.com/About"
const xml = `<loc>${escapeXml(canonical)}</loc>`;
// "<loc>https://example.com/About</loc>"
}
// Clamp out-of-range priorities gracefully
const userPriority = 1.5;
const safeAttr = `<priority>${formatPriority(userPriority)}</priority>`;
// "<priority>1.00</priority>"
```
## API Reference
### `isValidUrl(url: string): boolean`
Returns `true` when `url` parses as a valid absolute URL with `http:` or `https:` protocol. Returns `false` for any other protocol, relative URLs, or malformed strings.
**Throws:** never — all errors are caught internally.
### `normalizeUrl(url: string): string`
Lowercases the protocol and hostname of the parsed URL and returns the
reconstituted string. If the URL cannot be parsed, the original string is
returned unchanged.
**Throws:** never.
### `escapeXml(str: string): string`
Replaces `&` → `&amp;`, `<` → `&lt;`, `>` → `&gt;`, `"` → `&quot;`,
`'` → `&apos;`. Suitable for both element content and attribute values.
**Throws:** never.
### `formatPriority(priority: number): string`
Clamps `priority` to the closed interval `[0.0, 1.0]` using `Math.max` /
`Math.min`, then calls `.toFixed(2)` to produce a two-decimal-place string
(e.g. `"0.50"`).
**Throws:** never.
## Implementation Notes
`isValidUrl` and `normalizeUrl` deliberately delegate to the platform `URL`
constructor rather than attempting regex parsing. This gives accurate results
for IDNs, percent-encoded paths, and IPv6 host literals without maintaining a
custom parser. The `try/catch` wrappers ensure the helpers never throw
regardless of input.
`escapeXml` uses a sequence of `.replace()` calls rather than a single regex
with a callback to keep the implementation readable and free of closure
overhead for the common case.

View File

@@ -0,0 +1,141 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
escapeXml,
formatPriority,
isValidUrl,
normalizeUrl,
} from "./url-utils.js";
describe("isValidUrl", () => {
it("accepts http URL", { timeout: 5000 }, () => {
expect(isValidUrl("http://example.com/")).toBe(true);
});
it("accepts https URL", { timeout: 5000 }, () => {
expect(isValidUrl("https://example.com/page")).toBe(true);
});
it("accepts URL with query string", { timeout: 5000 }, () => {
expect(isValidUrl("https://example.com/search?q=hello&page=1")).toBe(
true
);
});
it("rejects ftp URL", { timeout: 5000 }, () => {
expect(isValidUrl("ftp://example.com/file.txt")).toBe(false);
});
it("rejects mailto URL", { timeout: 5000 }, () => {
expect(isValidUrl("mailto:user@example.com")).toBe(false);
});
it("rejects relative URL", { timeout: 5000 }, () => {
expect(isValidUrl("/relative/path")).toBe(false);
});
it("rejects empty string", { timeout: 5000 }, () => {
expect(isValidUrl("")).toBe(false);
});
it("rejects malformed string", { timeout: 5000 }, () => {
expect(isValidUrl("not a url at all")).toBe(false);
});
it("rejects file:// URL", { timeout: 5000 }, () => {
expect(isValidUrl("file:///etc/passwd")).toBe(false);
});
});
describe("normalizeUrl", () => {
it("lowercases scheme", { timeout: 5000 }, () => {
const result = normalizeUrl("HTTPS://example.com/");
expect(result.startsWith("https://")).toBe(true);
});
it("lowercases hostname", { timeout: 5000 }, () => {
const result = normalizeUrl("https://EXAMPLE.COM/page");
expect(result).toContain("example.com");
});
it("preserves path case", { timeout: 5000 }, () => {
const result = normalizeUrl("https://example.com/MyPage");
expect(result).toContain("/MyPage");
});
it("preserves query string", { timeout: 5000 }, () => {
const result = normalizeUrl("https://example.com/?foo=bar");
expect(result).toContain("foo=bar");
});
it("returns original string on unparseable input", {
timeout: 5000,
}, () => {
const bad = "not-a-url";
expect(normalizeUrl(bad)).toBe(bad);
});
});
describe("escapeXml", () => {
it("escapes ampersand", { timeout: 5000 }, () => {
expect(escapeXml("a & b")).toBe("a &amp; b");
});
it("escapes less-than", { timeout: 5000 }, () => {
expect(escapeXml("a < b")).toBe("a &lt; b");
});
it("escapes greater-than", { timeout: 5000 }, () => {
expect(escapeXml("a > b")).toBe("a &gt; b");
});
it("escapes double quote", { timeout: 5000 }, () => {
expect(escapeXml('say "hello"')).toBe("say &quot;hello&quot;");
});
it("escapes single quote", { timeout: 5000 }, () => {
expect(escapeXml("it's fine")).toBe("it&apos;s fine");
});
it("escapes multiple special chars", { timeout: 5000 }, () => {
expect(escapeXml('<a href="test&val">text\'s</a>')).toBe(
"&lt;a href=&quot;test&amp;val&quot;&gt;text&apos;s&lt;/a&gt;"
);
});
it("leaves plain text unchanged", { timeout: 5000 }, () => {
expect(escapeXml("hello world")).toBe("hello world");
});
it("handles empty string", { timeout: 5000 }, () => {
expect(escapeXml("")).toBe("");
});
});
describe("formatPriority", () => {
it("formats 0.5 to two decimal places", { timeout: 5000 }, () => {
expect(formatPriority(0.5)).toBe("0.50");
});
it("formats 1.0 to two decimal places", { timeout: 5000 }, () => {
expect(formatPriority(1.0)).toBe("1.00");
});
it("formats 0.0 to two decimal places", { timeout: 5000 }, () => {
expect(formatPriority(0.0)).toBe("0.00");
});
it("clamps values above 1.0", { timeout: 5000 }, () => {
expect(formatPriority(1.5)).toBe("1.00");
expect(formatPriority(99)).toBe("1.00");
});
it("clamps values below 0.0", { timeout: 5000 }, () => {
expect(formatPriority(-0.1)).toBe("0.00");
expect(formatPriority(-99)).toBe("0.00");
});
it("formats a mid-range value with rounding", { timeout: 5000 }, () => {
expect(formatPriority(0.333)).toBe("0.33");
expect(formatPriority(0.666)).toBe("0.67");
});
});

View File

@@ -0,0 +1,99 @@
/**
* URL and XML utility helpers for sitemap generation.
*/
/**
* Returns true if the string is an absolute `http` or `https` URL.
*
* Uses the platform-native `URL` constructor for authoritative parsing;
* only `http:` and `https:` protocols are permitted per the sitemaps.org spec.
*
* @param url - The string to test.
* @returns `true` when the string is a valid absolute http(s) URL.
*
* @example
* ```ts
* isValidUrl("https://example.com/"); // true
* isValidUrl("ftp://example.com/"); // false
* isValidUrl("not-a-url"); // false
* ```
*/
export function isValidUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
/**
* Normalises an absolute URL for use in sitemaps.
*
* - Lowercases the scheme and host components.
* - Removes a trailing slash from the pathname only when the path is exactly
* `"/"` plus one segment with no query/hash (avoids stripping the root `/`).
*
* @param url - An absolute URL string.
* @returns The normalised URL string.
*
* @example
* ```ts
* normalizeUrl("HTTPS://Example.COM/Path/"); // "https://example.com/Path/"
* normalizeUrl("https://example.com/page/"); // "https://example.com/page/"
* ```
*/
export function normalizeUrl(url: string): string {
try {
const parsed = new URL(url);
// Lowercase protocol and host
parsed.protocol = parsed.protocol.toLowerCase();
parsed.hostname = parsed.hostname.toLowerCase();
return parsed.toString();
} catch {
return url;
}
}
/**
* Escapes characters that have special meaning in XML.
*
* Covers the five predefined XML entities: `&`, `<`, `>`, `"`, `'`.
*
* @param str - Raw string that may contain XML-unsafe characters.
* @returns The escaped string safe for inclusion in XML element content or
* attribute values.
*
* @example
* ```ts
* escapeXml('Hello & "World" <ok>');
* // "Hello &amp; &quot;World&quot; &lt;ok&gt;"
* ```
*/
export function escapeXml(str: string): string {
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
/**
* Formats a priority value as a fixed two-decimal-place string, clamped to
* the `[0.0, 1.0]` range required by the sitemaps.org protocol.
*
* @param priority - A floating-point priority value.
* @returns A string like `"0.50"` or `"1.00"`.
*
* @example
* ```ts
* formatPriority(0.5); // "0.50"
* formatPriority(1.2); // "1.00"
* formatPriority(-0.1); // "0.00"
* ```
*/
export function formatPriority(priority: number): string {
const clamped = Math.max(0, Math.min(1, priority));
return clamped.toFixed(2);
}

View File

@@ -0,0 +1,39 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("dep-graph", () => {
it("has no @suaveplan/* runtime deps (leaf package)", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as {
dependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
};
const runtimeScoped = Object.keys(pkg.dependencies ?? {})
.filter((name) => name.startsWith("@suaveplan/"))
.sort();
expect(runtimeScoped).toEqual([]);
const peerScoped = Object.keys(pkg.peerDependencies ?? {}).filter(
(name) => name.startsWith("@suaveplan/")
);
expect(peerScoped).toEqual([]);
});
it("runtime deps are exactly the expected set (sorted)", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { dependencies?: Record<string, string> };
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
expect(runtime).toEqual([
"@opentelemetry/api",
"fast-xml-parser",
"tslib",
"zod",
]);
});
});

View File

@@ -0,0 +1,41 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const SRC = join(__dirname, "..", "src");
const BANNED: ReadonlyArray<RegExp> = [
/\bnode:fs\b/,
/\bnode:path\b/,
/\bnode:url\b/,
/\bprocess\./,
/\bBuffer\b/,
/\bdocument\b/,
/\bwindow\b/,
];
function* walk(dir: string): Generator<string> {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) yield* walk(full);
else if (full.endsWith(".ts") && !full.endsWith(".test.ts")) yield full;
}
}
describe("no-disallowed", () => {
it("src/ does not reference Node or browser built-ins", {
timeout: 5000,
}, () => {
const hits: string[] = [];
for (const file of walk(SRC)) {
const content = readFileSync(file, "utf8");
for (const pattern of BANNED) {
if (pattern.test(content)) {
hits.push(`${file} matched ${pattern}`);
}
}
}
expect(hits).toEqual([]);
});
});

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,20 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("tree-shake", () => {
it("sideEffects is false", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { sideEffects?: boolean };
expect(pkg.sideEffects).toBe(false);
});
it("exports has at least 5 entries", { timeout: 5000 }, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { exports?: Record<string, unknown> };
expect(pkg.exports).toBeDefined();
expect(Object.keys(pkg.exports ?? {}).length).toBeGreaterThanOrEqual(5);
});
});

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,20 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/sitemap",
plugins: [dts({ entryRoot: "src", tsconfigPath: "./tsconfig.lib.json", insertTypesEntry: true })],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: { preserveModules: true, preserveModulesRoot: "src", entryFileNames: "[name].js" },
external: ["fast-xml-parser", "zod", "tslib", "@opentelemetry/api", /^node:/],
},
},
});

View File

@@ -0,0 +1,26 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.schema.ts",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 5000,
},
});