Initial Commit
This commit is contained in:
69
packages/devtools/docs/sitemap/README.md
Normal file
69
packages/devtools/docs/sitemap/README.md
Normal 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";
|
||||
```
|
||||
101
packages/devtools/docs/sitemap/package.json
Normal file
101
packages/devtools/docs/sitemap/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
5
packages/devtools/docs/sitemap/src/core/builder/index.ts
Normal file
5
packages/devtools/docs/sitemap/src/core/builder/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
createSitemap,
|
||||
SitemapBuilder,
|
||||
type SitemapBuilderOptions,
|
||||
} from "./sitemap-builder";
|
||||
@@ -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.
|
||||
@@ -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&b=2");
|
||||
expect(xml).toContain("A & B <test>");
|
||||
});
|
||||
|
||||
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>");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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&y=2");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
createSitemapIndex,
|
||||
SitemapIndexBuilder,
|
||||
type SitemapIndexBuilderOptions,
|
||||
} from "./index-builder";
|
||||
11
packages/devtools/docs/sitemap/src/core/index.ts
Normal file
11
packages/devtools/docs/sitemap/src/core/index.ts
Normal 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";
|
||||
1
packages/devtools/docs/sitemap/src/core/parser/index.ts
Normal file
1
packages/devtools/docs/sitemap/src/core/parser/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { parseSitemap, parseSitemapIndex } from "./sitemap-parser";
|
||||
127
packages/devtools/docs/sitemap/src/core/parser/sitemap-parser.md
Normal file
127
packages/devtools/docs/sitemap/src/core/parser/sitemap-parser.md
Normal 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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
263
packages/devtools/docs/sitemap/src/core/parser/sitemap-parser.ts
Normal file
263
packages/devtools/docs/sitemap/src/core/parser/sitemap-parser.ts
Normal 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 };
|
||||
}
|
||||
1
packages/devtools/docs/sitemap/src/errors/index.ts
Normal file
1
packages/devtools/docs/sitemap/src/errors/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { SITEMAP_CODES, type SitemapCode, SitemapError } from "./sitemap-error";
|
||||
103
packages/devtools/docs/sitemap/src/errors/sitemap-error.md
Normal file
103
packages/devtools/docs/sitemap/src/errors/sitemap-error.md
Normal 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.
|
||||
39
packages/devtools/docs/sitemap/src/errors/sitemap-error.ts
Normal file
39
packages/devtools/docs/sitemap/src/errors/sitemap-error.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
46
packages/devtools/docs/sitemap/src/index.ts
Normal file
46
packages/devtools/docs/sitemap/src/index.ts
Normal 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";
|
||||
14
packages/devtools/docs/sitemap/src/schemas/index.ts
Normal file
14
packages/devtools/docs/sitemap/src/schemas/index.ts
Normal 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";
|
||||
113
packages/devtools/docs/sitemap/src/schemas/sitemap.schema.md
Normal file
113
packages/devtools/docs/sitemap/src/schemas/sitemap.schema.md
Normal 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`.
|
||||
90
packages/devtools/docs/sitemap/src/schemas/sitemap.schema.ts
Normal file
90
packages/devtools/docs/sitemap/src/schemas/sitemap.schema.ts
Normal 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>;
|
||||
6
packages/devtools/docs/sitemap/src/utils/index.ts
Normal file
6
packages/devtools/docs/sitemap/src/utils/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
escapeXml,
|
||||
formatPriority,
|
||||
isValidUrl,
|
||||
normalizeUrl,
|
||||
} from "./url-utils";
|
||||
97
packages/devtools/docs/sitemap/src/utils/url-utils.md
Normal file
97
packages/devtools/docs/sitemap/src/utils/url-utils.md
Normal 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 & b < 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 `&` → `&`, `<` → `<`, `>` → `>`, `"` → `"`,
|
||||
`'` → `'`. 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.
|
||||
141
packages/devtools/docs/sitemap/src/utils/url-utils.test.ts
Normal file
141
packages/devtools/docs/sitemap/src/utils/url-utils.test.ts
Normal 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 & b");
|
||||
});
|
||||
|
||||
it("escapes less-than", { timeout: 5000 }, () => {
|
||||
expect(escapeXml("a < b")).toBe("a < b");
|
||||
});
|
||||
|
||||
it("escapes greater-than", { timeout: 5000 }, () => {
|
||||
expect(escapeXml("a > b")).toBe("a > b");
|
||||
});
|
||||
|
||||
it("escapes double quote", { timeout: 5000 }, () => {
|
||||
expect(escapeXml('say "hello"')).toBe("say "hello"");
|
||||
});
|
||||
|
||||
it("escapes single quote", { timeout: 5000 }, () => {
|
||||
expect(escapeXml("it's fine")).toBe("it's fine");
|
||||
});
|
||||
|
||||
it("escapes multiple special chars", { timeout: 5000 }, () => {
|
||||
expect(escapeXml('<a href="test&val">text\'s</a>')).toBe(
|
||||
"<a href="test&val">text's</a>"
|
||||
);
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
99
packages/devtools/docs/sitemap/src/utils/url-utils.ts
Normal file
99
packages/devtools/docs/sitemap/src/utils/url-utils.ts
Normal 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 & "World" <ok>"
|
||||
* ```
|
||||
*/
|
||||
export function escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
39
packages/devtools/docs/sitemap/tests/dep-graph.test.ts
Normal file
39
packages/devtools/docs/sitemap/tests/dep-graph.test.ts
Normal 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
41
packages/devtools/docs/sitemap/tests/no-disallowed.test.ts
Normal file
41
packages/devtools/docs/sitemap/tests/no-disallowed.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
1
packages/devtools/docs/sitemap/tests/setup/polyfills.ts
Normal file
1
packages/devtools/docs/sitemap/tests/setup/polyfills.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
20
packages/devtools/docs/sitemap/tests/tree-shake.test.ts
Normal file
20
packages/devtools/docs/sitemap/tests/tree-shake.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
12
packages/devtools/docs/sitemap/tsconfig.lib.json
Normal file
12
packages/devtools/docs/sitemap/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
20
packages/devtools/docs/sitemap/vite.config.ts
Normal file
20
packages/devtools/docs/sitemap/vite.config.ts
Normal 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:/],
|
||||
},
|
||||
},
|
||||
});
|
||||
26
packages/devtools/docs/sitemap/vitest.config.ts
Normal file
26
packages/devtools/docs/sitemap/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user