@warlock.js/sitemap 5.19.1 → 5.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -1
- package/cjs/index.cjs +194 -10
- package/cjs/index.cjs.map +1 -1
- package/esm/generation-manifest.d.mts +40 -0
- package/esm/generation-manifest.mjs +114 -0
- package/esm/generation-manifest.mjs.map +1 -0
- package/esm/index.d.mts +3 -2
- package/esm/index.mjs +2 -1
- package/esm/normalize-entry.mjs +19 -1
- package/esm/normalize-entry.mjs.map +1 -1
- package/esm/sitemap-index-options.mjs +10 -1
- package/esm/sitemap-index-options.mjs.map +1 -1
- package/esm/sitemap-index-types.d.mts +5 -3
- package/esm/sitemap-index-xml.mjs +2 -2
- package/esm/sitemap-index-xml.mjs.map +1 -1
- package/esm/sitemap-index.d.mts +2 -0
- package/esm/sitemap-index.mjs +13 -2
- package/esm/sitemap-index.mjs.map +1 -1
- package/esm/sitemap-shard-writer.mjs +9 -3
- package/esm/sitemap-shard-writer.mjs.map +1 -1
- package/esm/sitemap.d.mts +3 -0
- package/esm/sitemap.mjs +12 -1
- package/esm/sitemap.mjs.map +1 -1
- package/esm/types.d.mts +13 -3
- package/esm/xml.mjs +12 -2
- package/esm/xml.mjs.map +1 -1
- package/llms-full.txt +190 -161
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/skills/sitemap-overview/SKILL.md +190 -161
|
@@ -7,6 +7,7 @@ const PROTOCOL_MAX_BYTES_PER_FILE = 50 * 1024 * 1024;
|
|
|
7
7
|
/** Validates and folds in defaults, once, at the constructor — the same discipline as `Sitemap`. */
|
|
8
8
|
function normalizeSitemapIndexOptions(options) {
|
|
9
9
|
const baseUrl = normalizeBaseUrl(options?.baseUrl);
|
|
10
|
+
const shardPathPrefix = normalizeShardPathPrefix(options.shardPathPrefix);
|
|
10
11
|
const maxUrlsPerFile = options.maxUrlsPerFile ?? 5e4;
|
|
11
12
|
const maxBytesPerFile = options.maxBytesPerFile ?? 52428800;
|
|
12
13
|
if (!Number.isInteger(maxUrlsPerFile) || maxUrlsPerFile < 1 || maxUrlsPerFile > 5e4) throw new RangeError(`maxUrlsPerFile must be an integer between 1 and the sitemaps.org ceiling of ${PROTOCOL_MAX_URLS_PER_FILE}, got ${JSON.stringify(maxUrlsPerFile)}.`);
|
|
@@ -15,16 +16,24 @@ function normalizeSitemapIndexOptions(options) {
|
|
|
15
16
|
baseUrl,
|
|
16
17
|
filePrefix: options.filePrefix ?? "sitemap",
|
|
17
18
|
indexFileName: options.indexFileName ?? "sitemap_index.xml",
|
|
19
|
+
shardPathPrefix,
|
|
18
20
|
gzip: options.gzip ?? false,
|
|
19
21
|
maxUrlsPerFile,
|
|
20
22
|
maxBytesPerFile,
|
|
21
23
|
defaults: {
|
|
22
24
|
changefreq: options.changefreq,
|
|
23
25
|
priority: options.priority,
|
|
24
|
-
lastmod: options.lastmod
|
|
26
|
+
lastmod: options.lastmod,
|
|
27
|
+
onImageLimitExceeded: options.onImageLimitExceeded
|
|
25
28
|
}
|
|
26
29
|
};
|
|
27
30
|
}
|
|
31
|
+
function normalizeShardPathPrefix(value) {
|
|
32
|
+
if (value === void 0 || value === "" || value === "/") return "";
|
|
33
|
+
const prefix = value.replace(/^\//, "").replace(/\/$/, "");
|
|
34
|
+
if (prefix.length === 0 || prefix.split("/").some((segment) => !/^[A-Za-z0-9_-]+$/.test(segment))) throw new TypeError("shardPathPrefix must contain only safe URL path segments.");
|
|
35
|
+
return `${prefix}/`;
|
|
36
|
+
}
|
|
28
37
|
|
|
29
38
|
//#endregion
|
|
30
39
|
export { normalizeSitemapIndexOptions };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sitemap-index-options.mjs","names":[],"sources":["../../../../../../sitemap/src/sitemap-index-options.ts"],"sourcesContent":["import type { SitemapOptions } from \"./types\";\nimport { normalizeBaseUrl } from \"./url\";\nimport type { SitemapIndexOptions } from \"./sitemap-index-types\";\n\n/** The sitemaps.org limits. Never clamped to — a silently clamped option is a lie about what was written. */\nexport const PROTOCOL_MAX_URLS_PER_FILE = 50_000;\nexport const PROTOCOL_MAX_BYTES_PER_FILE = 50 * 1024 * 1024;\n\nexport type ResolvedSitemapIndexOptions = {\n readonly baseUrl: string;\n readonly filePrefix: string;\n readonly indexFileName: string;\n readonly gzip: boolean;\n readonly maxUrlsPerFile: number;\n readonly maxBytesPerFile: number;\n readonly defaults: Pick
|
|
1
|
+
{"version":3,"file":"sitemap-index-options.mjs","names":[],"sources":["../../../../../../sitemap/src/sitemap-index-options.ts"],"sourcesContent":["import type { SitemapOptions } from \"./types\";\nimport { normalizeBaseUrl } from \"./url\";\nimport type { SitemapIndexOptions } from \"./sitemap-index-types\";\n\n/** The sitemaps.org limits. Never clamped to — a silently clamped option is a lie about what was written. */\nexport const PROTOCOL_MAX_URLS_PER_FILE = 50_000;\nexport const PROTOCOL_MAX_BYTES_PER_FILE = 50 * 1024 * 1024;\n\nexport type ResolvedSitemapIndexOptions = {\n readonly baseUrl: string;\n readonly filePrefix: string;\n readonly indexFileName: string;\n readonly shardPathPrefix: string;\n readonly gzip: boolean;\n readonly maxUrlsPerFile: number;\n readonly maxBytesPerFile: number;\n readonly defaults: Pick<\n SitemapOptions,\n \"changefreq\" | \"priority\" | \"lastmod\" | \"onImageLimitExceeded\"\n >;\n};\n\n/** Validates and folds in defaults, once, at the constructor — the same discipline as `Sitemap`. */\nexport function normalizeSitemapIndexOptions(\n options: SitemapIndexOptions,\n): ResolvedSitemapIndexOptions {\n const baseUrl = normalizeBaseUrl(options?.baseUrl);\n const shardPathPrefix = normalizeShardPathPrefix(options.shardPathPrefix);\n\n const maxUrlsPerFile = options.maxUrlsPerFile ?? PROTOCOL_MAX_URLS_PER_FILE;\n const maxBytesPerFile = options.maxBytesPerFile ?? PROTOCOL_MAX_BYTES_PER_FILE;\n\n if (\n !Number.isInteger(maxUrlsPerFile) ||\n maxUrlsPerFile < 1 ||\n maxUrlsPerFile > PROTOCOL_MAX_URLS_PER_FILE\n ) {\n throw new RangeError(\n `maxUrlsPerFile must be an integer between 1 and the sitemaps.org ceiling of ` +\n `${PROTOCOL_MAX_URLS_PER_FILE}, got ${JSON.stringify(maxUrlsPerFile)}.`,\n );\n }\n\n if (\n !Number.isFinite(maxBytesPerFile) ||\n maxBytesPerFile < 1 ||\n maxBytesPerFile > PROTOCOL_MAX_BYTES_PER_FILE\n ) {\n throw new RangeError(\n `maxBytesPerFile must be between 1 and the sitemaps.org ceiling of ` +\n `${PROTOCOL_MAX_BYTES_PER_FILE} bytes, got ${JSON.stringify(maxBytesPerFile)}.`,\n );\n }\n\n return {\n baseUrl,\n filePrefix: options.filePrefix ?? \"sitemap\",\n indexFileName: options.indexFileName ?? \"sitemap_index.xml\",\n shardPathPrefix,\n gzip: options.gzip ?? false,\n maxUrlsPerFile,\n maxBytesPerFile,\n defaults: {\n changefreq: options.changefreq,\n priority: options.priority,\n lastmod: options.lastmod,\n onImageLimitExceeded: options.onImageLimitExceeded,\n },\n };\n}\n\nfunction normalizeShardPathPrefix(value: string | undefined): string {\n if (value === undefined || value === \"\" || value === \"/\") return \"\";\n\n const prefix = value.replace(/^\\//, \"\").replace(/\\/$/, \"\");\n if (\n prefix.length === 0 ||\n prefix.split(\"/\").some((segment) => !/^[A-Za-z0-9_-]+$/.test(segment))\n ) {\n throw new TypeError(\"shardPathPrefix must contain only safe URL path segments.\");\n }\n\n return `${prefix}/`;\n}\n"],"mappings":";;;;AAKA,MAAa,6BAA6B;AAC1C,MAAa,8BAA8B,KAAK,OAAO;;AAiBvD,SAAgB,6BACd,SAC6B;CAC7B,MAAM,UAAU,iBAAiB,SAAS,OAAO;CACjD,MAAM,kBAAkB,yBAAyB,QAAQ,eAAe;CAExE,MAAM,iBAAiB,QAAQ;CAC/B,MAAM,kBAAkB,QAAQ;CAEhC,IACE,CAAC,OAAO,UAAU,cAAc,KAChC,iBAAiB,KACjB,sBAEA,MAAM,IAAI,WACR,+EACK,2BAA2B,QAAQ,KAAK,UAAU,cAAc,EAAE,EACzE;CAGF,IACE,CAAC,OAAO,SAAS,eAAe,KAChC,kBAAkB,KAClB,4BAEA,MAAM,IAAI,WACR,qEACK,4BAA4B,cAAc,KAAK,UAAU,eAAe,EAAE,EACjF;CAGF,OAAO;EACL;EACA,YAAY,QAAQ,cAAc;EAClC,eAAe,QAAQ,iBAAiB;EACxC;EACA,MAAM,QAAQ,QAAQ;EACtB;EACA;EACA,UAAU;GACR,YAAY,QAAQ;GACpB,UAAU,QAAQ;GAClB,SAAS,QAAQ;GACjB,sBAAsB,QAAQ;EAChC;CACF;AACF;AAEA,SAAS,yBAAyB,OAAmC;CACnE,IAAI,UAAU,UAAa,UAAU,MAAM,UAAU,KAAK,OAAO;CAEjE,MAAM,SAAS,MAAM,QAAQ,OAAO,EAAE,CAAC,CAAC,QAAQ,OAAO,EAAE;CACzD,IACE,OAAO,WAAW,KAClB,OAAO,MAAM,GAAG,CAAC,CAAC,MAAM,YAAY,CAAC,mBAAmB,KAAK,OAAO,CAAC,GAErE,MAAM,IAAI,UAAU,2DAA2D;CAGjF,OAAO,GAAG,OAAO;AACnB"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ChangeFreq, DuplicateReport, RouteSummary, SitemapEntry } from "./types.mjs";
|
|
1
|
+
import { ChangeFreq, DuplicateReport, RouteSummary, SitemapEntry, SitemapImageLimitExceeded } from "./types.mjs";
|
|
2
2
|
|
|
3
3
|
//#region ../sitemap/src/sitemap-index-types.d.ts
|
|
4
4
|
/** What a source factory produces: one walk over a group's entries. */
|
|
@@ -12,13 +12,15 @@ type SitemapSourceFactory = () => SitemapSource | Promise<SitemapSource>;
|
|
|
12
12
|
type SitemapIndexOptions = {
|
|
13
13
|
/** Absolute origin, validated exactly as `Sitemap` validates it. */readonly baseUrl: string; /** Shard file name prefix. Default `sitemap`, giving `sitemap-0001.xml`. */
|
|
14
14
|
readonly filePrefix?: string; /** Index file name. Default `sitemap_index.xml`. */
|
|
15
|
-
readonly indexFileName?: string; /**
|
|
15
|
+
readonly indexFileName?: string; /** URL directory for shard links, independent of their on-disk directory. */
|
|
16
|
+
readonly shardPathPrefix?: string; /** Write `.xml.gz` beside each shard and point the index at it. Default false. */
|
|
16
17
|
readonly gzip?: boolean; /** Hard ceiling per shard. Default 50_000; never accepted above the protocol ceiling. */
|
|
17
18
|
readonly maxUrlsPerFile?: number; /** Hard ceiling per shard in bytes, uncompressed. Default 50 * 1024 * 1024. */
|
|
18
19
|
readonly maxBytesPerFile?: number;
|
|
19
20
|
readonly changefreq?: ChangeFreq;
|
|
20
21
|
readonly priority?: number;
|
|
21
|
-
readonly lastmod?: string | Date;
|
|
22
|
+
readonly lastmod?: string | Date; /** Called once per route when an entry exceeds Google's 1,000-image limit. */
|
|
23
|
+
readonly onImageLimitExceeded?: (event: SitemapImageLimitExceeded) => void;
|
|
22
24
|
};
|
|
23
25
|
type SitemapSetResult = {
|
|
24
26
|
readonly indexPath: string;
|
|
@@ -8,9 +8,9 @@ import { escapeXml } from "./xml.mjs";
|
|
|
8
8
|
* nested per-group indexes, and never a zero-url row (that is a diagnostic
|
|
9
9
|
* for `files`, not something a crawler should be told to fetch).
|
|
10
10
|
*/
|
|
11
|
-
function buildSitemapIndexXml(files, baseUrl) {
|
|
11
|
+
function buildSitemapIndexXml(files, baseUrl, shardPathPrefix = "") {
|
|
12
12
|
const body = files.filter((file) => file.urls > 0).map((file) => {
|
|
13
|
-
return ` <sitemap>\n <loc>${escapeXml(joinOrigin(baseUrl, file.fileName))}</loc>\n </sitemap>`;
|
|
13
|
+
return ` <sitemap>\n <loc>${escapeXml(joinOrigin(baseUrl, `${shardPathPrefix}${file.fileName}`))}</loc>\n </sitemap>`;
|
|
14
14
|
}).join("\n");
|
|
15
15
|
return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<sitemapindex xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n" + (body.length > 0 ? `${body}\n` : "") + `</sitemapindex>\n`;
|
|
16
16
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sitemap-index-xml.mjs","names":[],"sources":["../../../../../../sitemap/src/sitemap-index-xml.ts"],"sourcesContent":["import { escapeXml } from \"./xml\";\nimport { joinOrigin } from \"./url\";\nimport type { ShardFile } from \"./sitemap-shard-writer\";\n\n/**\n * Serialises the flat master `sitemapindex` document: every shard of every\n * group, directly, in the order the groups and shards were produced — no\n * nested per-group indexes, and never a zero-url row (that is a diagnostic\n * for `files`, not something a crawler should be told to fetch).\n */\nexport function buildSitemapIndexXml(files: readonly ShardFile[]
|
|
1
|
+
{"version":3,"file":"sitemap-index-xml.mjs","names":[],"sources":["../../../../../../sitemap/src/sitemap-index-xml.ts"],"sourcesContent":["import { escapeXml } from \"./xml\";\nimport { joinOrigin } from \"./url\";\nimport type { ShardFile } from \"./sitemap-shard-writer\";\n\n/**\n * Serialises the flat master `sitemapindex` document: every shard of every\n * group, directly, in the order the groups and shards were produced — no\n * nested per-group indexes, and never a zero-url row (that is a diagnostic\n * for `files`, not something a crawler should be told to fetch).\n */\nexport function buildSitemapIndexXml(\n files: readonly ShardFile[],\n baseUrl: string,\n shardPathPrefix = \"\",\n): string {\n const body = files\n .filter((file) => file.urls > 0)\n .map((file) => {\n const loc = escapeXml(joinOrigin(baseUrl, `${shardPathPrefix}${file.fileName}`));\n\n return ` <sitemap>\\n <loc>${loc}</loc>\\n </sitemap>`;\n })\n .join(\"\\n\");\n\n return (\n `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\\n` +\n `<sitemapindex xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\\n` +\n (body.length > 0 ? `${body}\\n` : \"\") +\n `</sitemapindex>\\n`\n );\n}\n"],"mappings":";;;;;;;;;;AAUA,SAAgB,qBACd,OACA,SACA,kBAAkB,IACV;CACR,MAAM,OAAO,MACV,QAAQ,SAAS,KAAK,OAAO,CAAC,CAAC,CAC/B,KAAK,SAAS;EAGb,OAAO,yBAFK,UAAU,WAAW,SAAS,GAAG,kBAAkB,KAAK,UAAU,CAE5C,EAAE;CACtC,CAAC,CAAC,CACD,KAAK,IAAI;CAEZ,OACE,wHAEC,KAAK,SAAS,IAAI,GAAG,KAAK,MAAM,MACjC;AAEJ"}
|
package/esm/sitemap-index.d.mts
CHANGED
|
@@ -15,6 +15,7 @@ declare class SitemapIndex {
|
|
|
15
15
|
private readonly namedGroups;
|
|
16
16
|
/** Canonical (lowercased) keys already registered, so a case collision is caught at `addSource()`. */
|
|
17
17
|
private readonly registeredKeys;
|
|
18
|
+
private readonly warnedImageRoutes;
|
|
18
19
|
constructor(options: SitemapIndexOptions);
|
|
19
20
|
addSource(source: SitemapSourceFactory): this;
|
|
20
21
|
addSource(key: string, source: SitemapSourceFactory): this;
|
|
@@ -25,6 +26,7 @@ declare class SitemapIndex {
|
|
|
25
26
|
* named groups key-then-ordinal.
|
|
26
27
|
*/
|
|
27
28
|
saveTo(outDir: string): Promise<SitemapSetResult>;
|
|
29
|
+
private reportImageLimit;
|
|
28
30
|
}
|
|
29
31
|
//#endregion
|
|
30
32
|
export { SitemapIndex };
|
package/esm/sitemap-index.mjs
CHANGED
|
@@ -24,6 +24,7 @@ var SitemapIndex = class {
|
|
|
24
24
|
namedGroups = [];
|
|
25
25
|
/** Canonical (lowercased) keys already registered, so a case collision is caught at `addSource()`. */
|
|
26
26
|
registeredKeys = /* @__PURE__ */ new Set();
|
|
27
|
+
warnedImageRoutes = /* @__PURE__ */ new Set();
|
|
27
28
|
constructor(options) {
|
|
28
29
|
this.options = normalizeSitemapIndexOptions(options);
|
|
29
30
|
}
|
|
@@ -63,13 +64,16 @@ var SitemapIndex = class {
|
|
|
63
64
|
gzip: this.options.gzip,
|
|
64
65
|
maxUrlsPerFile: this.options.maxUrlsPerFile,
|
|
65
66
|
maxBytesPerFile: this.options.maxBytesPerFile,
|
|
66
|
-
defaults:
|
|
67
|
+
defaults: {
|
|
68
|
+
...this.options.defaults,
|
|
69
|
+
onImageLimitExceeded: (event) => this.reportImageLimit(event)
|
|
70
|
+
},
|
|
67
71
|
duplicates,
|
|
68
72
|
routes
|
|
69
73
|
});
|
|
70
74
|
shardFiles.push(...files);
|
|
71
75
|
}
|
|
72
|
-
const indexXml = buildSitemapIndexXml(shardFiles, this.options.baseUrl);
|
|
76
|
+
const indexXml = buildSitemapIndexXml(shardFiles, this.options.baseUrl, this.options.shardPathPrefix);
|
|
73
77
|
await writeFile(join(tempDir, this.options.indexFileName), indexXml, "utf8");
|
|
74
78
|
return [...shardFiles.filter((file) => file.urls > 0).map((file) => file.fileName), this.options.indexFileName];
|
|
75
79
|
};
|
|
@@ -89,6 +93,13 @@ var SitemapIndex = class {
|
|
|
89
93
|
routes: routes.summary()
|
|
90
94
|
};
|
|
91
95
|
}
|
|
96
|
+
reportImageLimit(event) {
|
|
97
|
+
const key = event.route ?? "unattributed";
|
|
98
|
+
if (this.warnedImageRoutes.has(key)) return;
|
|
99
|
+
this.warnedImageRoutes.add(key);
|
|
100
|
+
if (this.options.defaults.onImageLimitExceeded) this.options.defaults.onImageLimitExceeded(event);
|
|
101
|
+
else console.warn(`[warlock:sitemap] ${key} exceeded the 1,000-image limit; dropped ${event.dropped} image(s).`);
|
|
102
|
+
}
|
|
92
103
|
};
|
|
93
104
|
|
|
94
105
|
//#endregion
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sitemap-index.mjs","names":[],"sources":["../../../../../../sitemap/src/sitemap-index.ts"],"sourcesContent":["import { join } from \"node:path\";\nimport { writeFile } from \"node:fs/promises\";\nimport { DuplicateSourceKeyError } from \"./errors\";\nimport {\n normalizeSitemapIndexOptions,\n type ResolvedSitemapIndexOptions,\n} from \"./sitemap-index-options\";\nimport { canonicalizeSourceKey } from \"./shard-name\";\nimport { publishAtomically } from \"./atomic-publish\";\nimport { writeShardGroup, type ShardFile, type ShardGroup } from \"./sitemap-shard-writer\";\nimport { buildSitemapIndexXml } from \"./sitemap-index-xml\";\nimport { DuplicatePathTracker } from \"./duplicate-path-tracker\";\nimport { RouteCounter } from \"./route-counter\";\nimport type {\n SitemapFileResult,\n SitemapIndexOptions,\n SitemapSetResult,\n SitemapSourceFactory,\n} from \"./sitemap-index-types\";\n\n/**\n * The streaming path for 300–500K URLs: shards, one flat master index, gzip,\n * and an atomic publish. It is a SECOND mode, not a bigger `Sitemap` — it\n * retains only the current shard's buffer and the set of paths it has seen.\n * There is no `entries()`, `toXML()` or `size`; anyone who can afford those\n * is in `Sitemap` and should be there instead.\n */\nexport class SitemapIndex {\n private readonly options: ResolvedSitemapIndexOptions;\n\n /** Every unnamed `addSource(factory)` call merges into this one group, sharing one shard counter. */\n private readonly unnamedFactories: SitemapSourceFactory[] = [];\n\n private readonly namedGroups: ShardGroup[] = [];\n\n /** Canonical (lowercased) keys already registered, so a case collision is caught at `addSource()`. */\n private readonly registeredKeys = new Set<string>();\n\n public constructor(options: SitemapIndexOptions) {\n this.options = normalizeSitemapIndexOptions(options);\n }\n\n public addSource(source: SitemapSourceFactory): this;\n public addSource(key: string, source: SitemapSourceFactory): this;\n public addSource(\n keyOrSource: string | SitemapSourceFactory,\n maybeSource?: SitemapSourceFactory,\n ): this {\n if (typeof keyOrSource === \"string\") {\n const canonicalKey = canonicalizeSourceKey(keyOrSource);\n\n if (this.registeredKeys.has(canonicalKey)) {\n throw new DuplicateSourceKeyError(keyOrSource);\n }\n\n this.registeredKeys.add(canonicalKey);\n this.namedGroups.push({ key: keyOrSource, factories: [maybeSource as SitemapSourceFactory] });\n } else {\n this.unnamedFactories.push(keyOrSource);\n }\n\n return this;\n }\n\n /**\n * Walks every group one at a time — never all at once — into a sibling\n * temp directory, then publishes the whole set atomically. `files` is\n * reported in registration order: the unnamed group first (if any), then\n * named groups key-then-ordinal.\n */\n public async saveTo(outDir: string): Promise<SitemapSetResult> {\n const groups: ShardGroup[] = [\n ...(this.unnamedFactories.length > 0\n ? [{ key: undefined, factories: this.unnamedFactories }]\n : []),\n ...this.namedGroups,\n ];\n\n const duplicates = new DuplicatePathTracker();\n const routes = new RouteCounter();\n let shardFiles: ShardFile[] = [];\n\n const write = async (tempDir: string): Promise<readonly string[]> => {\n shardFiles = [];\n\n for (const group of groups) {\n const files = await writeShardGroup(group, {\n tempDir,\n baseUrl: this.options.baseUrl,\n filePrefix: this.options.filePrefix,\n gzip: this.options.gzip,\n maxUrlsPerFile: this.options.maxUrlsPerFile,\n maxBytesPerFile: this.options.maxBytesPerFile,\n defaults: this.options.defaults,\n duplicates,\n routes,\n });\n\n shardFiles.push(...files);\n }\n\n const indexXml = buildSitemapIndexXml(shardFiles
|
|
1
|
+
{"version":3,"file":"sitemap-index.mjs","names":[],"sources":["../../../../../../sitemap/src/sitemap-index.ts"],"sourcesContent":["import { join } from \"node:path\";\nimport { writeFile } from \"node:fs/promises\";\nimport { DuplicateSourceKeyError } from \"./errors\";\nimport {\n normalizeSitemapIndexOptions,\n type ResolvedSitemapIndexOptions,\n} from \"./sitemap-index-options\";\nimport { canonicalizeSourceKey } from \"./shard-name\";\nimport { publishAtomically } from \"./atomic-publish\";\nimport { writeShardGroup, type ShardFile, type ShardGroup } from \"./sitemap-shard-writer\";\nimport { buildSitemapIndexXml } from \"./sitemap-index-xml\";\nimport { DuplicatePathTracker } from \"./duplicate-path-tracker\";\nimport { RouteCounter } from \"./route-counter\";\nimport type {\n SitemapFileResult,\n SitemapIndexOptions,\n SitemapSetResult,\n SitemapSourceFactory,\n} from \"./sitemap-index-types\";\n\n/**\n * The streaming path for 300–500K URLs: shards, one flat master index, gzip,\n * and an atomic publish. It is a SECOND mode, not a bigger `Sitemap` — it\n * retains only the current shard's buffer and the set of paths it has seen.\n * There is no `entries()`, `toXML()` or `size`; anyone who can afford those\n * is in `Sitemap` and should be there instead.\n */\nexport class SitemapIndex {\n private readonly options: ResolvedSitemapIndexOptions;\n\n /** Every unnamed `addSource(factory)` call merges into this one group, sharing one shard counter. */\n private readonly unnamedFactories: SitemapSourceFactory[] = [];\n\n private readonly namedGroups: ShardGroup[] = [];\n\n /** Canonical (lowercased) keys already registered, so a case collision is caught at `addSource()`. */\n private readonly registeredKeys = new Set<string>();\n\n private readonly warnedImageRoutes = new Set<string>();\n\n public constructor(options: SitemapIndexOptions) {\n this.options = normalizeSitemapIndexOptions(options);\n }\n\n public addSource(source: SitemapSourceFactory): this;\n public addSource(key: string, source: SitemapSourceFactory): this;\n public addSource(\n keyOrSource: string | SitemapSourceFactory,\n maybeSource?: SitemapSourceFactory,\n ): this {\n if (typeof keyOrSource === \"string\") {\n const canonicalKey = canonicalizeSourceKey(keyOrSource);\n\n if (this.registeredKeys.has(canonicalKey)) {\n throw new DuplicateSourceKeyError(keyOrSource);\n }\n\n this.registeredKeys.add(canonicalKey);\n this.namedGroups.push({ key: keyOrSource, factories: [maybeSource as SitemapSourceFactory] });\n } else {\n this.unnamedFactories.push(keyOrSource);\n }\n\n return this;\n }\n\n /**\n * Walks every group one at a time — never all at once — into a sibling\n * temp directory, then publishes the whole set atomically. `files` is\n * reported in registration order: the unnamed group first (if any), then\n * named groups key-then-ordinal.\n */\n public async saveTo(outDir: string): Promise<SitemapSetResult> {\n const groups: ShardGroup[] = [\n ...(this.unnamedFactories.length > 0\n ? [{ key: undefined, factories: this.unnamedFactories }]\n : []),\n ...this.namedGroups,\n ];\n\n const duplicates = new DuplicatePathTracker();\n const routes = new RouteCounter();\n let shardFiles: ShardFile[] = [];\n\n const write = async (tempDir: string): Promise<readonly string[]> => {\n shardFiles = [];\n\n for (const group of groups) {\n const files = await writeShardGroup(group, {\n tempDir,\n baseUrl: this.options.baseUrl,\n filePrefix: this.options.filePrefix,\n gzip: this.options.gzip,\n maxUrlsPerFile: this.options.maxUrlsPerFile,\n maxBytesPerFile: this.options.maxBytesPerFile,\n defaults: {\n ...this.options.defaults,\n onImageLimitExceeded: (event) => this.reportImageLimit(event),\n },\n duplicates,\n routes,\n });\n\n shardFiles.push(...files);\n }\n\n const indexXml = buildSitemapIndexXml(\n shardFiles,\n this.options.baseUrl,\n this.options.shardPathPrefix,\n );\n\n await writeFile(join(tempDir, this.options.indexFileName), indexXml, \"utf8\");\n\n const writtenShardNames = shardFiles\n .filter((file) => file.urls > 0)\n .map((file) => file.fileName);\n\n return [...writtenShardNames, this.options.indexFileName];\n };\n\n await publishAtomically(outDir, write);\n\n const files: SitemapFileResult[] = shardFiles.map((file) => ({\n path: join(outDir, file.fileName),\n ...(file.key !== undefined ? { key: file.key } : {}),\n urls: file.urls,\n bytes: file.bytes,\n gzipped: file.gzipped,\n }));\n\n return {\n indexPath: join(outDir, this.options.indexFileName),\n files,\n totalUrls: files.reduce((total, file) => total + file.urls, 0),\n duplicates: duplicates.report(),\n routes: routes.summary(),\n };\n }\n\n private reportImageLimit(event: { readonly route?: string; readonly dropped: number }): void {\n const key = event.route ?? \"unattributed\";\n if (this.warnedImageRoutes.has(key)) return;\n\n this.warnedImageRoutes.add(key);\n if (this.options.defaults.onImageLimitExceeded)\n this.options.defaults.onImageLimitExceeded(event);\n else\n console.warn(\n `[warlock:sitemap] ${key} exceeded the 1,000-image limit; dropped ${event.dropped} image(s).`,\n );\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AA2BA,IAAa,eAAb,MAA0B;CACxB,AAAiB;;CAGjB,AAAiB,mBAA2C,CAAC;CAE7D,AAAiB,cAA4B,CAAC;;CAG9C,AAAiB,iCAAiB,IAAI,IAAY;CAElD,AAAiB,oCAAoB,IAAI,IAAY;CAErD,AAAO,YAAY,SAA8B;EAC/C,KAAK,UAAU,6BAA6B,OAAO;CACrD;CAIA,AAAO,UACL,aACA,aACM;EACN,IAAI,OAAO,gBAAgB,UAAU;GACnC,MAAM,eAAe,sBAAsB,WAAW;GAEtD,IAAI,KAAK,eAAe,IAAI,YAAY,GACtC,MAAM,IAAI,wBAAwB,WAAW;GAG/C,KAAK,eAAe,IAAI,YAAY;GACpC,KAAK,YAAY,KAAK;IAAE,KAAK;IAAa,WAAW,CAAC,WAAmC;GAAE,CAAC;EAC9F,OACE,KAAK,iBAAiB,KAAK,WAAW;EAGxC,OAAO;CACT;;;;;;;CAQA,MAAa,OAAO,QAA2C;EAC7D,MAAM,SAAuB,CAC3B,GAAI,KAAK,iBAAiB,SAAS,IAC/B,CAAC;GAAE,KAAK;GAAW,WAAW,KAAK;EAAiB,CAAC,IACrD,CAAC,GACL,GAAG,KAAK,WACV;EAEA,MAAM,aAAa,IAAI,qBAAqB;EAC5C,MAAM,SAAS,IAAI,aAAa;EAChC,IAAI,aAA0B,CAAC;EAE/B,MAAM,QAAQ,OAAO,YAAgD;GACnE,aAAa,CAAC;GAEd,KAAK,MAAM,SAAS,QAAQ;IAC1B,MAAM,QAAQ,MAAM,gBAAgB,OAAO;KACzC;KACA,SAAS,KAAK,QAAQ;KACtB,YAAY,KAAK,QAAQ;KACzB,MAAM,KAAK,QAAQ;KACnB,gBAAgB,KAAK,QAAQ;KAC7B,iBAAiB,KAAK,QAAQ;KAC9B,UAAU;MACR,GAAG,KAAK,QAAQ;MAChB,uBAAuB,UAAU,KAAK,iBAAiB,KAAK;KAC9D;KACA;KACA;IACF,CAAC;IAED,WAAW,KAAK,GAAG,KAAK;GAC1B;GAEA,MAAM,WAAW,qBACf,YACA,KAAK,QAAQ,SACb,KAAK,QAAQ,eACf;GAEA,MAAM,UAAU,KAAK,SAAS,KAAK,QAAQ,aAAa,GAAG,UAAU,MAAM;GAM3E,OAAO,CAAC,GAJkB,WACvB,QAAQ,SAAS,KAAK,OAAO,CAAC,CAAC,CAC/B,KAAK,SAAS,KAAK,QAEK,GAAG,KAAK,QAAQ,aAAa;EAC1D;EAEA,MAAM,kBAAkB,QAAQ,KAAK;EAErC,MAAM,QAA6B,WAAW,KAAK,UAAU;GAC3D,MAAM,KAAK,QAAQ,KAAK,QAAQ;GAChC,GAAI,KAAK,QAAQ,SAAY,EAAE,KAAK,KAAK,IAAI,IAAI,CAAC;GAClD,MAAM,KAAK;GACX,OAAO,KAAK;GACZ,SAAS,KAAK;EAChB,EAAE;EAEF,OAAO;GACL,WAAW,KAAK,QAAQ,KAAK,QAAQ,aAAa;GAClD;GACA,WAAW,MAAM,QAAQ,OAAO,SAAS,QAAQ,KAAK,MAAM,CAAC;GAC7D,YAAY,WAAW,OAAO;GAC9B,QAAQ,OAAO,QAAQ;EACzB;CACF;CAEA,AAAQ,iBAAiB,OAAoE;EAC3F,MAAM,MAAM,MAAM,SAAS;EAC3B,IAAI,KAAK,kBAAkB,IAAI,GAAG,GAAG;EAErC,KAAK,kBAAkB,IAAI,GAAG;EAC9B,IAAI,KAAK,QAAQ,SAAS,sBACxB,KAAK,QAAQ,SAAS,qBAAqB,KAAK;OAEhD,QAAQ,KACN,qBAAqB,IAAI,2CAA2C,MAAM,QAAQ,WACpF;CACJ;AACF"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { normalizeEntry } from "./normalize-entry.mjs";
|
|
2
|
-
import { XHTML_NAMESPACE_ATTR, buildSitemapXml, renderUrlBlock } from "./xml.mjs";
|
|
2
|
+
import { IMAGE_NAMESPACE_ATTR, XHTML_NAMESPACE_ATTR, buildSitemapXml, renderUrlBlock } from "./xml.mjs";
|
|
3
3
|
import { shardFileName } from "./shard-name.mjs";
|
|
4
4
|
import { writeFile } from "node:fs/promises";
|
|
5
5
|
import { join } from "node:path";
|
|
@@ -17,6 +17,7 @@ function envelopeBytes(baseUrl) {
|
|
|
17
17
|
* shard's real bytes on disk exceed the ceiling it was rolled against.
|
|
18
18
|
*/
|
|
19
19
|
const NAMESPACE_BYTES = Buffer.byteLength(XHTML_NAMESPACE_ATTR, "utf8");
|
|
20
|
+
const IMAGE_NAMESPACE_BYTES = Buffer.byteLength(IMAGE_NAMESPACE_ATTR, "utf8");
|
|
20
21
|
async function writeShardFile(ctx, fileName, entries) {
|
|
21
22
|
const xml = buildSitemapXml(entries, ctx.baseUrl);
|
|
22
23
|
const filePath = join(ctx.tempDir, fileName);
|
|
@@ -38,6 +39,7 @@ async function writeShardGroup(group, ctx) {
|
|
|
38
39
|
let buffer = [];
|
|
39
40
|
let bufferBytes = envelopeBytes(ctx.baseUrl);
|
|
40
41
|
let bufferHasAlternates = false;
|
|
42
|
+
let bufferHasImages = false;
|
|
41
43
|
let ordinal = 1;
|
|
42
44
|
const flush = async () => {
|
|
43
45
|
if (buffer.length === 0) return;
|
|
@@ -54,6 +56,7 @@ async function writeShardGroup(group, ctx) {
|
|
|
54
56
|
buffer = [];
|
|
55
57
|
bufferBytes = envelopeBytes(ctx.baseUrl);
|
|
56
58
|
bufferHasAlternates = false;
|
|
59
|
+
bufferHasImages = false;
|
|
57
60
|
};
|
|
58
61
|
for (const factory of group.factories) {
|
|
59
62
|
const source = await factory();
|
|
@@ -61,15 +64,18 @@ async function writeShardGroup(group, ctx) {
|
|
|
61
64
|
const resolved = normalizeEntry(rawEntry, ctx.defaults);
|
|
62
65
|
if (!ctx.duplicates.attempt(resolved.path, resolved.route)) continue;
|
|
63
66
|
const entryHasAlternates = (resolved.alternates?.length ?? 0) > 0;
|
|
67
|
+
const entryHasImages = (resolved.images?.length ?? 0) > 0;
|
|
64
68
|
const blockBytes = Buffer.byteLength(renderUrlBlock(resolved, ctx.baseUrl), "utf8") + 1;
|
|
65
|
-
const addedBytes = blockBytes + (entryHasAlternates && !bufferHasAlternates ? NAMESPACE_BYTES : 0);
|
|
69
|
+
const addedBytes = blockBytes + (entryHasAlternates && !bufferHasAlternates ? NAMESPACE_BYTES : 0) + (entryHasImages && !bufferHasImages ? IMAGE_NAMESPACE_BYTES : 0);
|
|
66
70
|
const hitsUrlCeiling = buffer.length >= ctx.maxUrlsPerFile;
|
|
67
71
|
const hitsByteCeiling = buffer.length > 0 && bufferBytes + addedBytes > ctx.maxBytesPerFile;
|
|
68
72
|
if (hitsUrlCeiling || hitsByteCeiling) await flush();
|
|
69
73
|
const addsNamespaceNow = entryHasAlternates && !bufferHasAlternates;
|
|
74
|
+
const addsImageNamespaceNow = entryHasImages && !bufferHasImages;
|
|
70
75
|
buffer.push(resolved);
|
|
71
|
-
bufferBytes += blockBytes + (addsNamespaceNow ? NAMESPACE_BYTES : 0);
|
|
76
|
+
bufferBytes += blockBytes + (addsNamespaceNow ? NAMESPACE_BYTES : 0) + (addsImageNamespaceNow ? IMAGE_NAMESPACE_BYTES : 0);
|
|
72
77
|
if (addsNamespaceNow) bufferHasAlternates = true;
|
|
78
|
+
if (addsImageNamespaceNow) bufferHasImages = true;
|
|
73
79
|
ctx.routes.record(resolved.route);
|
|
74
80
|
}
|
|
75
81
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sitemap-shard-writer.mjs","names":[],"sources":["../../../../../../sitemap/src/sitemap-shard-writer.ts"],"sourcesContent":["import { writeFile } from \"node:fs/promises\";\nimport { gzipSync } from \"node:zlib\";\nimport { join } from \"node:path\";\nimport { normalizeEntry } from \"./normalize-entry\";\nimport { shardFileName } from \"./shard-name\";\nimport type { ResolvedSitemapEntry, SitemapOptions } from \"./types\";\nimport type { DuplicatePathTracker } from \"./duplicate-path-tracker\";\nimport type { RouteCounter } from \"./route-counter\";\nimport type { SitemapSourceFactory } from \"./sitemap-index-types\";\nimport { buildSitemapXml, renderUrlBlock, XHTML_NAMESPACE_ATTR } from \"./xml\";\n\n/** One `addSource` group: the unnamed group merges every unkeyed call; a keyed group holds exactly one factory. */\nexport type ShardGroup = {\n readonly key?: string;\n readonly factories: readonly SitemapSourceFactory[];\n};\n\nexport type ShardWriterContext = {\n readonly tempDir: string;\n readonly baseUrl: string;\n readonly filePrefix: string;\n readonly gzip: boolean;\n readonly maxUrlsPerFile: number;\n readonly maxBytesPerFile: number;\n readonly defaults: Pick
|
|
1
|
+
{"version":3,"file":"sitemap-shard-writer.mjs","names":[],"sources":["../../../../../../sitemap/src/sitemap-shard-writer.ts"],"sourcesContent":["import { writeFile } from \"node:fs/promises\";\nimport { gzipSync } from \"node:zlib\";\nimport { join } from \"node:path\";\nimport { normalizeEntry } from \"./normalize-entry\";\nimport { shardFileName } from \"./shard-name\";\nimport type { ResolvedSitemapEntry, SitemapOptions } from \"./types\";\nimport type { DuplicatePathTracker } from \"./duplicate-path-tracker\";\nimport type { RouteCounter } from \"./route-counter\";\nimport type { SitemapSourceFactory } from \"./sitemap-index-types\";\nimport { buildSitemapXml, IMAGE_NAMESPACE_ATTR, renderUrlBlock, XHTML_NAMESPACE_ATTR } from \"./xml\";\n\n/** One `addSource` group: the unnamed group merges every unkeyed call; a keyed group holds exactly one factory. */\nexport type ShardGroup = {\n readonly key?: string;\n readonly factories: readonly SitemapSourceFactory[];\n};\n\nexport type ShardWriterContext = {\n readonly tempDir: string;\n readonly baseUrl: string;\n readonly filePrefix: string;\n readonly gzip: boolean;\n readonly maxUrlsPerFile: number;\n readonly maxBytesPerFile: number;\n readonly defaults: Pick<\n SitemapOptions,\n \"changefreq\" | \"priority\" | \"lastmod\" | \"onImageLimitExceeded\"\n >;\n readonly duplicates: DuplicatePathTracker;\n readonly routes: RouteCounter;\n};\n\n/** A shard actually written, or the zero-url row reported for a group that produced nothing. */\nexport type ShardFile = {\n readonly fileName: string;\n readonly key?: string;\n readonly urls: number;\n readonly bytes: number;\n readonly gzipped: boolean;\n};\n\nfunction envelopeBytes(baseUrl: string): number {\n return Buffer.byteLength(buildSitemapXml([], baseUrl), \"utf8\");\n}\n\n/**\n * The extra bytes `<urlset>` gains for `xmlns:xhtml=\"…\"` once a shard holds\n * an entry with alternates. Charged separately from `envelopeBytes()` so the\n * ceiling check can add it exactly once — the moment the first alternate\n * enters the buffer — rather than missing it entirely, which would let a\n * shard's real bytes on disk exceed the ceiling it was rolled against.\n */\nconst NAMESPACE_BYTES = Buffer.byteLength(XHTML_NAMESPACE_ATTR, \"utf8\");\nconst IMAGE_NAMESPACE_BYTES = Buffer.byteLength(IMAGE_NAMESPACE_ATTR, \"utf8\");\n\nasync function writeShardFile(\n ctx: ShardWriterContext,\n fileName: string,\n entries: readonly ResolvedSitemapEntry[],\n): Promise<{ bytes: number }> {\n const xml = buildSitemapXml(entries, ctx.baseUrl);\n const filePath = join(ctx.tempDir, fileName);\n\n if (ctx.gzip) {\n const compressed = gzipSync(Buffer.from(xml, \"utf8\"));\n\n await writeFile(filePath, compressed);\n\n return { bytes: compressed.byteLength };\n }\n\n await writeFile(filePath, xml, \"utf8\");\n\n return { bytes: Buffer.byteLength(xml, \"utf8\") };\n}\n\n/**\n * Walks one group's factories, one at a time, rolling to a new shard on\n * whichever ceiling — URL count or serialised bytes — is hit first. Holds\n * only the current shard's buffer, never the whole group.\n */\nexport async function writeShardGroup(\n group: ShardGroup,\n ctx: ShardWriterContext,\n): Promise<ShardFile[]> {\n const results: ShardFile[] = [];\n\n let buffer: ResolvedSitemapEntry[] = [];\n let bufferBytes = envelopeBytes(ctx.baseUrl);\n // Charged once, the moment the buffer's first alternate-bearing entry is added — mirrors\n // buildSitemapXml()'s own \"at least one entry has alternates\" rule for the same shard.\n let bufferHasAlternates = false;\n let bufferHasImages = false;\n let ordinal = 1;\n\n const flush = async () => {\n if (buffer.length === 0) return;\n\n const fileName = shardFileName(ctx.filePrefix, group.key, ordinal, ctx.gzip);\n const { bytes } = await writeShardFile(ctx, fileName, buffer);\n\n results.push({ fileName, key: group.key, urls: buffer.length, bytes, gzipped: ctx.gzip });\n\n ordinal += 1;\n buffer = [];\n bufferBytes = envelopeBytes(ctx.baseUrl);\n bufferHasAlternates = false;\n bufferHasImages = false;\n };\n\n for (const factory of group.factories) {\n const source = await factory();\n\n for await (const rawEntry of source) {\n const resolved = normalizeEntry(rawEntry, ctx.defaults);\n\n // Duplicate paths are SKIPPED, not overridden: the earlier one is already on disk.\n if (!ctx.duplicates.attempt(resolved.path, resolved.route)) continue;\n\n const entryHasAlternates = (resolved.alternates?.length ?? 0) > 0;\n const entryHasImages = (resolved.images?.length ?? 0) > 0;\n const blockBytes = Buffer.byteLength(renderUrlBlock(resolved, ctx.baseUrl), \"utf8\") + 1;\n // What this entry would add to the CURRENT shard: its own block, plus the namespace\n // attribute if this is the shard's first alternate and the buffer doesn't carry it yet.\n const addedBytes =\n blockBytes +\n (entryHasAlternates && !bufferHasAlternates ? NAMESPACE_BYTES : 0) +\n (entryHasImages && !bufferHasImages ? IMAGE_NAMESPACE_BYTES : 0);\n\n const hitsUrlCeiling = buffer.length >= ctx.maxUrlsPerFile;\n // A single entry can never be split, so the byte ceiling only rolls an already-nonempty shard.\n const hitsByteCeiling = buffer.length > 0 && bufferBytes + addedBytes > ctx.maxBytesPerFile;\n\n if (hitsUrlCeiling || hitsByteCeiling) await flush();\n\n const addsNamespaceNow = entryHasAlternates && !bufferHasAlternates;\n const addsImageNamespaceNow = entryHasImages && !bufferHasImages;\n\n buffer.push(resolved);\n bufferBytes +=\n blockBytes +\n (addsNamespaceNow ? NAMESPACE_BYTES : 0) +\n (addsImageNamespaceNow ? IMAGE_NAMESPACE_BYTES : 0);\n if (addsNamespaceNow) bufferHasAlternates = true;\n if (addsImageNamespaceNow) bufferHasImages = true;\n ctx.routes.record(resolved.route);\n }\n }\n\n await flush();\n\n if (results.length === 0) {\n // Reported, never written: an empty shard in an index is a section someone lost.\n results.push({\n fileName: shardFileName(ctx.filePrefix, group.key, 1, ctx.gzip),\n key: group.key,\n urls: 0,\n bytes: 0,\n gzipped: false,\n });\n }\n\n return results;\n}\n"],"mappings":";;;;;;;;AAyCA,SAAS,cAAc,SAAyB;CAC9C,OAAO,OAAO,WAAW,gBAAgB,CAAC,GAAG,OAAO,GAAG,MAAM;AAC/D;;;;;;;;AASA,MAAM,kBAAkB,OAAO,WAAW,sBAAsB,MAAM;AACtE,MAAM,wBAAwB,OAAO,WAAW,sBAAsB,MAAM;AAE5E,eAAe,eACb,KACA,UACA,SAC4B;CAC5B,MAAM,MAAM,gBAAgB,SAAS,IAAI,OAAO;CAChD,MAAM,WAAW,KAAK,IAAI,SAAS,QAAQ;CAE3C,IAAI,IAAI,MAAM;EACZ,MAAM,aAAa,SAAS,OAAO,KAAK,KAAK,MAAM,CAAC;EAEpD,MAAM,UAAU,UAAU,UAAU;EAEpC,OAAO,EAAE,OAAO,WAAW,WAAW;CACxC;CAEA,MAAM,UAAU,UAAU,KAAK,MAAM;CAErC,OAAO,EAAE,OAAO,OAAO,WAAW,KAAK,MAAM,EAAE;AACjD;;;;;;AAOA,eAAsB,gBACpB,OACA,KACsB;CACtB,MAAM,UAAuB,CAAC;CAE9B,IAAI,SAAiC,CAAC;CACtC,IAAI,cAAc,cAAc,IAAI,OAAO;CAG3C,IAAI,sBAAsB;CAC1B,IAAI,kBAAkB;CACtB,IAAI,UAAU;CAEd,MAAM,QAAQ,YAAY;EACxB,IAAI,OAAO,WAAW,GAAG;EAEzB,MAAM,WAAW,cAAc,IAAI,YAAY,MAAM,KAAK,SAAS,IAAI,IAAI;EAC3E,MAAM,EAAE,UAAU,MAAM,eAAe,KAAK,UAAU,MAAM;EAE5D,QAAQ,KAAK;GAAE;GAAU,KAAK,MAAM;GAAK,MAAM,OAAO;GAAQ;GAAO,SAAS,IAAI;EAAK,CAAC;EAExF,WAAW;EACX,SAAS,CAAC;EACV,cAAc,cAAc,IAAI,OAAO;EACvC,sBAAsB;EACtB,kBAAkB;CACpB;CAEA,KAAK,MAAM,WAAW,MAAM,WAAW;EACrC,MAAM,SAAS,MAAM,QAAQ;EAE7B,WAAW,MAAM,YAAY,QAAQ;GACnC,MAAM,WAAW,eAAe,UAAU,IAAI,QAAQ;GAGtD,IAAI,CAAC,IAAI,WAAW,QAAQ,SAAS,MAAM,SAAS,KAAK,GAAG;GAE5D,MAAM,sBAAsB,SAAS,YAAY,UAAU,KAAK;GAChE,MAAM,kBAAkB,SAAS,QAAQ,UAAU,KAAK;GACxD,MAAM,aAAa,OAAO,WAAW,eAAe,UAAU,IAAI,OAAO,GAAG,MAAM,IAAI;GAGtF,MAAM,aACJ,cACC,sBAAsB,CAAC,sBAAsB,kBAAkB,MAC/D,kBAAkB,CAAC,kBAAkB,wBAAwB;GAEhE,MAAM,iBAAiB,OAAO,UAAU,IAAI;GAE5C,MAAM,kBAAkB,OAAO,SAAS,KAAK,cAAc,aAAa,IAAI;GAE5E,IAAI,kBAAkB,iBAAiB,MAAM,MAAM;GAEnD,MAAM,mBAAmB,sBAAsB,CAAC;GAChD,MAAM,wBAAwB,kBAAkB,CAAC;GAEjD,OAAO,KAAK,QAAQ;GACpB,eACE,cACC,mBAAmB,kBAAkB,MACrC,wBAAwB,wBAAwB;GACnD,IAAI,kBAAkB,sBAAsB;GAC5C,IAAI,uBAAuB,kBAAkB;GAC7C,IAAI,OAAO,OAAO,SAAS,KAAK;EAClC;CACF;CAEA,MAAM,MAAM;CAEZ,IAAI,QAAQ,WAAW,GAErB,QAAQ,KAAK;EACX,UAAU,cAAc,IAAI,YAAY,MAAM,KAAK,GAAG,IAAI,IAAI;EAC9D,KAAK,MAAM;EACX,MAAM;EACN,OAAO;EACP,SAAS;CACX,CAAC;CAGH,OAAO;AACT"}
|
package/esm/sitemap.d.mts
CHANGED
|
@@ -14,6 +14,8 @@ import { DuplicateReport, ResolvedSitemapEntry, RouteSummary, SitemapEntry, Site
|
|
|
14
14
|
declare class Sitemap {
|
|
15
15
|
private readonly baseUrl;
|
|
16
16
|
private readonly defaults;
|
|
17
|
+
private readonly warnedImageRoutes;
|
|
18
|
+
private readonly optionsImageReporter;
|
|
17
19
|
/** Keyed by path: a duplicate `<loc>` makes the document invalid, so the later add wins. */
|
|
18
20
|
private readonly entriesByPath;
|
|
19
21
|
/** Insertion order per path, so `duplicates()` can name every colliding source. */
|
|
@@ -21,6 +23,7 @@ declare class Sitemap {
|
|
|
21
23
|
/** Declared patterns, including ones that never contributed a URL. */
|
|
22
24
|
private readonly declaredRoutes;
|
|
23
25
|
constructor(options: SitemapOptions);
|
|
26
|
+
private reportImageLimit;
|
|
24
27
|
add(entry: SitemapEntry): this;
|
|
25
28
|
addMany(entries: Iterable<SitemapEntry>): this;
|
|
26
29
|
/**
|
package/esm/sitemap.mjs
CHANGED
|
@@ -20,6 +20,8 @@ import { dirname, join } from "node:path";
|
|
|
20
20
|
var Sitemap = class {
|
|
21
21
|
baseUrl;
|
|
22
22
|
defaults;
|
|
23
|
+
warnedImageRoutes = /* @__PURE__ */ new Set();
|
|
24
|
+
optionsImageReporter;
|
|
23
25
|
/** Keyed by path: a duplicate `<loc>` makes the document invalid, so the later add wins. */
|
|
24
26
|
entriesByPath = /* @__PURE__ */ new Map();
|
|
25
27
|
/** Insertion order per path, so `duplicates()` can name every colliding source. */
|
|
@@ -28,12 +30,21 @@ var Sitemap = class {
|
|
|
28
30
|
declaredRoutes = /* @__PURE__ */ new Set();
|
|
29
31
|
constructor(options) {
|
|
30
32
|
this.baseUrl = normalizeBaseUrl(options?.baseUrl);
|
|
33
|
+
this.optionsImageReporter = options.onImageLimitExceeded;
|
|
31
34
|
this.defaults = {
|
|
32
35
|
changefreq: options.changefreq,
|
|
33
36
|
priority: options.priority,
|
|
34
|
-
lastmod: options.lastmod
|
|
37
|
+
lastmod: options.lastmod,
|
|
38
|
+
onImageLimitExceeded: (event) => this.reportImageLimit(event)
|
|
35
39
|
};
|
|
36
40
|
}
|
|
41
|
+
reportImageLimit(event) {
|
|
42
|
+
const key = event.route ?? "unattributed";
|
|
43
|
+
if (this.warnedImageRoutes.has(key)) return;
|
|
44
|
+
this.warnedImageRoutes.add(key);
|
|
45
|
+
if (this.optionsImageReporter) this.optionsImageReporter(event);
|
|
46
|
+
else console.warn(`[warlock:sitemap] ${key} exceeded the 1,000-image limit; dropped ${event.dropped} image(s).`);
|
|
47
|
+
}
|
|
37
48
|
add(entry) {
|
|
38
49
|
const resolved = normalizeEntry(entry, this.defaults);
|
|
39
50
|
this.entriesByPath.set(resolved.path, resolved);
|
package/esm/sitemap.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sitemap.mjs","names":[],"sources":["../../../../../../sitemap/src/sitemap.ts"],"sourcesContent":["import { mkdir, writeFile } from \"node:fs/promises\";\nimport { dirname, join } from \"node:path\";\nimport { publishAtomically } from \"./atomic-publish\";\nimport { atomicWriteFile } from \"./atomic-write-file\";\nimport { normalizeEntry } from \"./normalize-entry\";\nimport type {\n DuplicateReport,\n ResolvedSitemapEntry,\n RouteSummary,\n SitemapEntry,\n SitemapOptions,\n} from \"./types\";\nimport { normalizeBaseUrl } from \"./url\";\nimport { buildSitemapXml } from \"./xml\";\n\n/**\n * A bounded sitemap builder: it RETAINS every entry, which is what makes\n * `entries()` and a repeatable `toXML()` possible, and is exactly right up to\n * the sitemaps.org ceiling of 50,000 URLs / 50MB.\n *\n * Above that ceiling this is the wrong tool — the streaming writer retains\n * nothing and emits shards plus an index instead. The two modes are separate\n * on purpose: a class that promised not to retain entries and still offered\n * `entries()` would be lying about one of them.\n */\nexport class Sitemap {\n private readonly baseUrl: string;\n\n private readonly defaults: Pick
|
|
1
|
+
{"version":3,"file":"sitemap.mjs","names":[],"sources":["../../../../../../sitemap/src/sitemap.ts"],"sourcesContent":["import { mkdir, writeFile } from \"node:fs/promises\";\nimport { dirname, join } from \"node:path\";\nimport { publishAtomically } from \"./atomic-publish\";\nimport { atomicWriteFile } from \"./atomic-write-file\";\nimport { normalizeEntry } from \"./normalize-entry\";\nimport type {\n DuplicateReport,\n ResolvedSitemapEntry,\n RouteSummary,\n SitemapEntry,\n SitemapOptions,\n} from \"./types\";\nimport { normalizeBaseUrl } from \"./url\";\nimport { buildSitemapXml } from \"./xml\";\n\n/**\n * A bounded sitemap builder: it RETAINS every entry, which is what makes\n * `entries()` and a repeatable `toXML()` possible, and is exactly right up to\n * the sitemaps.org ceiling of 50,000 URLs / 50MB.\n *\n * Above that ceiling this is the wrong tool — the streaming writer retains\n * nothing and emits shards plus an index instead. The two modes are separate\n * on purpose: a class that promised not to retain entries and still offered\n * `entries()` would be lying about one of them.\n */\nexport class Sitemap {\n private readonly baseUrl: string;\n\n private readonly defaults: Pick<\n SitemapOptions,\n \"changefreq\" | \"priority\" | \"lastmod\" | \"onImageLimitExceeded\"\n >;\n\n private readonly warnedImageRoutes = new Set<string>();\n\n private readonly optionsImageReporter: SitemapOptions[\"onImageLimitExceeded\"];\n\n /** Keyed by path: a duplicate `<loc>` makes the document invalid, so the later add wins. */\n private readonly entriesByPath = new Map<string, ResolvedSitemapEntry>();\n\n /** Insertion order per path, so `duplicates()` can name every colliding source. */\n private readonly routesByPath = new Map<string, (string | undefined)[]>();\n\n /** Declared patterns, including ones that never contributed a URL. */\n private readonly declaredRoutes = new Set<string>();\n\n public constructor(options: SitemapOptions) {\n this.baseUrl = normalizeBaseUrl(options?.baseUrl);\n this.optionsImageReporter = options.onImageLimitExceeded;\n this.defaults = {\n changefreq: options.changefreq,\n priority: options.priority,\n lastmod: options.lastmod,\n onImageLimitExceeded: (event) => this.reportImageLimit(event),\n };\n }\n\n private reportImageLimit(event: { readonly route?: string; readonly dropped: number }): void {\n const key = event.route ?? \"unattributed\";\n if (this.warnedImageRoutes.has(key)) return;\n\n this.warnedImageRoutes.add(key);\n if (this.optionsImageReporter) this.optionsImageReporter(event);\n else\n console.warn(\n `[warlock:sitemap] ${key} exceeded the 1,000-image limit; dropped ${event.dropped} image(s).`,\n );\n }\n\n public add(entry: SitemapEntry): this {\n const resolved = normalizeEntry(entry, this.defaults);\n\n this.entriesByPath.set(resolved.path, resolved);\n\n const seen = this.routesByPath.get(resolved.path);\n\n if (seen) {\n seen.push(resolved.route);\n } else {\n this.routesByPath.set(resolved.path, [resolved.route]);\n }\n\n if (resolved.route !== undefined) this.declaredRoutes.add(resolved.route);\n\n return this;\n }\n\n public addMany(entries: Iterable<SitemapEntry>): this {\n for (const entry of entries) this.add(entry);\n\n return this;\n }\n\n /**\n * Names a pattern the caller EXPECTS to contribute URLs, so that one which\n * contributes none shows up in `routes()` as a `count: 0` row instead of as\n * silence. A dynamic route whose supplier returned nothing is the failure\n * this package exists to make visible.\n */\n public declareRoute(route: string): this {\n this.declaredRoutes.add(route);\n\n return this;\n }\n\n public get size(): number {\n return this.entriesByPath.size;\n }\n\n public entries(): readonly ResolvedSitemapEntry[] {\n return [...this.entriesByPath.values()];\n }\n\n public routes(): readonly RouteSummary[] {\n const counts = new Map<string, number>();\n\n for (const route of this.declaredRoutes) counts.set(route, 0);\n\n for (const entry of this.entriesByPath.values()) {\n if (entry.route === undefined) continue;\n\n counts.set(entry.route, (counts.get(entry.route) ?? 0) + 1);\n }\n\n return [...counts].map(([route, count]) => ({ route, count }));\n }\n\n /**\n * Every path added more than once. The override is silent — it is not\n * hidden: a collision between static discovery and a dynamic supplier is a\n * real defect, and the CALLER decides whether it fails their build. This\n * package reports; it never prints and never throws over a duplicate.\n */\n public duplicates(): readonly DuplicateReport[] {\n const reports: DuplicateReport[] = [];\n\n for (const [path, routes] of this.routesByPath) {\n if (routes.length < 2) continue;\n\n reports.push({ path, count: routes.length, routes: [...routes] });\n }\n\n return reports;\n }\n\n /** Pure and repeatable: calling it twice returns the same string and mutates nothing. */\n public toXML(): string {\n return buildSitemapXml(this.entries(), this.baseUrl);\n }\n\n /**\n * Writes the document, creating parent directories so a clean checkout\n * works. The write is atomic: a failed or interrupted publish leaves\n * whatever was already at `filePath` untouched instead of truncating it.\n */\n public async saveTo(filePath: string): Promise<void> {\n await mkdir(dirname(filePath), { recursive: true });\n await atomicWriteFile(filePath, this.toXML());\n }\n\n /**\n * Publishes the document as the whole content of `outDir`, exactly the way\n * `SitemapIndex.saveTo` publishes a set: swapped in atomically, and marked\n * as owned. So a site that later outgrows one file can publish an index\n * into the same directory, and a later single file removes stale shards.\n * Refuses a non-empty directory this package did not write\n * (`UnownedOutputDirectoryError`). Returns the published file's path.\n */\n public async publishTo(outDir: string, fileName = \"sitemap.xml\"): Promise<string> {\n const xml = this.toXML();\n\n await publishAtomically(outDir, async (tempDir) => {\n await writeFile(join(tempDir, fileName), xml, \"utf8\");\n\n return [fileName];\n });\n\n return join(outDir, fileName);\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAyBA,IAAa,UAAb,MAAqB;CACnB,AAAiB;CAEjB,AAAiB;CAKjB,AAAiB,oCAAoB,IAAI,IAAY;CAErD,AAAiB;;CAGjB,AAAiB,gCAAgB,IAAI,IAAkC;;CAGvE,AAAiB,+BAAe,IAAI,IAAoC;;CAGxE,AAAiB,iCAAiB,IAAI,IAAY;CAElD,AAAO,YAAY,SAAyB;EAC1C,KAAK,UAAU,iBAAiB,SAAS,OAAO;EAChD,KAAK,uBAAuB,QAAQ;EACpC,KAAK,WAAW;GACd,YAAY,QAAQ;GACpB,UAAU,QAAQ;GAClB,SAAS,QAAQ;GACjB,uBAAuB,UAAU,KAAK,iBAAiB,KAAK;EAC9D;CACF;CAEA,AAAQ,iBAAiB,OAAoE;EAC3F,MAAM,MAAM,MAAM,SAAS;EAC3B,IAAI,KAAK,kBAAkB,IAAI,GAAG,GAAG;EAErC,KAAK,kBAAkB,IAAI,GAAG;EAC9B,IAAI,KAAK,sBAAsB,KAAK,qBAAqB,KAAK;OAE5D,QAAQ,KACN,qBAAqB,IAAI,2CAA2C,MAAM,QAAQ,WACpF;CACJ;CAEA,AAAO,IAAI,OAA2B;EACpC,MAAM,WAAW,eAAe,OAAO,KAAK,QAAQ;EAEpD,KAAK,cAAc,IAAI,SAAS,MAAM,QAAQ;EAE9C,MAAM,OAAO,KAAK,aAAa,IAAI,SAAS,IAAI;EAEhD,IAAI,MACF,KAAK,KAAK,SAAS,KAAK;OAExB,KAAK,aAAa,IAAI,SAAS,MAAM,CAAC,SAAS,KAAK,CAAC;EAGvD,IAAI,SAAS,UAAU,QAAW,KAAK,eAAe,IAAI,SAAS,KAAK;EAExE,OAAO;CACT;CAEA,AAAO,QAAQ,SAAuC;EACpD,KAAK,MAAM,SAAS,SAAS,KAAK,IAAI,KAAK;EAE3C,OAAO;CACT;;;;;;;CAQA,AAAO,aAAa,OAAqB;EACvC,KAAK,eAAe,IAAI,KAAK;EAE7B,OAAO;CACT;CAEA,IAAW,OAAe;EACxB,OAAO,KAAK,cAAc;CAC5B;CAEA,AAAO,UAA2C;EAChD,OAAO,CAAC,GAAG,KAAK,cAAc,OAAO,CAAC;CACxC;CAEA,AAAO,SAAkC;EACvC,MAAM,yBAAS,IAAI,IAAoB;EAEvC,KAAK,MAAM,SAAS,KAAK,gBAAgB,OAAO,IAAI,OAAO,CAAC;EAE5D,KAAK,MAAM,SAAS,KAAK,cAAc,OAAO,GAAG;GAC/C,IAAI,MAAM,UAAU,QAAW;GAE/B,OAAO,IAAI,MAAM,QAAQ,OAAO,IAAI,MAAM,KAAK,KAAK,KAAK,CAAC;EAC5D;EAEA,OAAO,CAAC,GAAG,MAAM,CAAC,CAAC,KAAK,CAAC,OAAO,YAAY;GAAE;GAAO;EAAM,EAAE;CAC/D;;;;;;;CAQA,AAAO,aAAyC;EAC9C,MAAM,UAA6B,CAAC;EAEpC,KAAK,MAAM,CAAC,MAAM,WAAW,KAAK,cAAc;GAC9C,IAAI,OAAO,SAAS,GAAG;GAEvB,QAAQ,KAAK;IAAE;IAAM,OAAO,OAAO;IAAQ,QAAQ,CAAC,GAAG,MAAM;GAAE,CAAC;EAClE;EAEA,OAAO;CACT;;CAGA,AAAO,QAAgB;EACrB,OAAO,gBAAgB,KAAK,QAAQ,GAAG,KAAK,OAAO;CACrD;;;;;;CAOA,MAAa,OAAO,UAAiC;EACnD,MAAM,MAAM,QAAQ,QAAQ,GAAG,EAAE,WAAW,KAAK,CAAC;EAClD,MAAM,gBAAgB,UAAU,KAAK,MAAM,CAAC;CAC9C;;;;;;;;;CAUA,MAAa,UAAU,QAAgB,WAAW,eAAgC;EAChF,MAAM,MAAM,KAAK,MAAM;EAEvB,MAAM,kBAAkB,QAAQ,OAAO,YAAY;GACjD,MAAM,UAAU,KAAK,SAAS,QAAQ,GAAG,KAAK,MAAM;GAEpD,OAAO,CAAC,QAAQ;EAClB,CAAC;EAED,OAAO,KAAK,QAAQ,QAAQ;CAC9B;AACF"}
|
package/esm/types.d.mts
CHANGED
|
@@ -14,6 +14,14 @@ type SitemapAlternate = {
|
|
|
14
14
|
readonly hreflang: string; /** A path resolved against `baseUrl`, or an absolute URL used as given. */
|
|
15
15
|
readonly path: string;
|
|
16
16
|
};
|
|
17
|
+
/** One image associated with a page URL in Google's image sitemap extension. */
|
|
18
|
+
type SitemapImage = {
|
|
19
|
+
/** An absolute HTTP(S) URL. It may point at a verified external CDN. */readonly loc: string;
|
|
20
|
+
};
|
|
21
|
+
type SitemapImageLimitExceeded = {
|
|
22
|
+
readonly route?: string;
|
|
23
|
+
readonly dropped: number;
|
|
24
|
+
};
|
|
17
25
|
/**
|
|
18
26
|
* One `<url>` block. Only `path` is required; `name` and `route` are carried
|
|
19
27
|
* for the diagnostics in `routes()` and `duplicates()` and never serialised.
|
|
@@ -25,7 +33,8 @@ type SitemapEntry = {
|
|
|
25
33
|
readonly lastmod?: string | Date;
|
|
26
34
|
readonly changefreq?: ChangeFreq;
|
|
27
35
|
readonly priority?: number; /** Language versions of THIS page, conventionally including itself. */
|
|
28
|
-
readonly alternates?: readonly SitemapAlternate[];
|
|
36
|
+
readonly alternates?: readonly SitemapAlternate[]; /** Images discoverable from this page. At most 1,000 are emitted per URL. */
|
|
37
|
+
readonly images?: readonly SitemapImage[];
|
|
29
38
|
};
|
|
30
39
|
/**
|
|
31
40
|
* An entry after normalisation: the path carries its leading slash, the
|
|
@@ -40,7 +49,8 @@ type SitemapOptions = {
|
|
|
40
49
|
/** Absolute origin: `https://example.com` or `https://example.com/base`. */readonly baseUrl: string; /** Applied to every entry that does not set its own. */
|
|
41
50
|
readonly changefreq?: ChangeFreq;
|
|
42
51
|
readonly priority?: number;
|
|
43
|
-
readonly lastmod?: string | Date;
|
|
52
|
+
readonly lastmod?: string | Date; /** Called once per route when an entry exceeds Google's 1,000-image limit. */
|
|
53
|
+
readonly onImageLimitExceeded?: (event: SitemapImageLimitExceeded) => void;
|
|
44
54
|
};
|
|
45
55
|
/**
|
|
46
56
|
* How many URLs a route contributed. A `count: 0` row is the interesting one:
|
|
@@ -60,5 +70,5 @@ type DuplicateReport = {
|
|
|
60
70
|
readonly routes: readonly (string | undefined)[];
|
|
61
71
|
};
|
|
62
72
|
//#endregion
|
|
63
|
-
export { ChangeFreq, DuplicateReport, ResolvedSitemapEntry, RouteSummary, SitemapAlternate, SitemapEntry, SitemapOptions };
|
|
73
|
+
export { ChangeFreq, DuplicateReport, ResolvedSitemapEntry, RouteSummary, SitemapAlternate, SitemapEntry, SitemapImage, SitemapImageLimitExceeded, SitemapOptions };
|
|
64
74
|
//# sourceMappingURL=types.d.mts.map
|
package/esm/xml.mjs
CHANGED
|
@@ -13,12 +13,15 @@ function escapeXml(value) {
|
|
|
13
13
|
return value.replace(/[&<>"']/g, (char) => XML_ESCAPES[char] ?? char);
|
|
14
14
|
}
|
|
15
15
|
const XHTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
|
|
16
|
+
const IMAGE_NAMESPACE = "http://www.google.com/schemas/sitemap-image/1.1";
|
|
16
17
|
/**
|
|
17
18
|
* The `xmlns:xhtml` attribute exactly as it appears on `<urlset>`, including
|
|
18
19
|
* its leading space. Exported so the shard writer can charge its byte length
|
|
19
20
|
* against the ceiling without duplicating the string it measures.
|
|
20
21
|
*/
|
|
21
22
|
const XHTML_NAMESPACE_ATTR = ` xmlns:xhtml="${XHTML_NAMESPACE}"`;
|
|
23
|
+
/** See {@link XHTML_NAMESPACE_ATTR}; measured by the shard writer before roll-over. */
|
|
24
|
+
const IMAGE_NAMESPACE_ATTR = ` xmlns:image="${IMAGE_NAMESPACE}"`;
|
|
22
25
|
/**
|
|
23
26
|
* Renders one `<url>` block. Exported so the streaming writer can measure the
|
|
24
27
|
* exact bytes it is about to append before deciding whether the byte ceiling
|
|
@@ -34,6 +37,11 @@ function renderUrlBlock(entry, baseUrl) {
|
|
|
34
37
|
const href = escapeXml(resolveAgainstBase(baseUrl, alternate.path));
|
|
35
38
|
lines.push(` <xhtml:link rel="alternate" hreflang="${escapeXml(alternate.hreflang)}" href="${href}"/>`);
|
|
36
39
|
}
|
|
40
|
+
for (const image of entry.images ?? []) {
|
|
41
|
+
lines.push(` <image:image>`);
|
|
42
|
+
lines.push(` <image:loc>${escapeXml(resolveAgainstBase(baseUrl, image.loc))}</image:loc>`);
|
|
43
|
+
lines.push(` </image:image>`);
|
|
44
|
+
}
|
|
37
45
|
lines.push(` </url>`);
|
|
38
46
|
return lines.join("\n");
|
|
39
47
|
}
|
|
@@ -47,11 +55,13 @@ function renderUrlBlock(entry, baseUrl) {
|
|
|
47
55
|
* alternate — an unused namespace on every single-language sitemap is noise.
|
|
48
56
|
*/
|
|
49
57
|
function buildSitemapXml(entries, baseUrl) {
|
|
50
|
-
const
|
|
58
|
+
const hasAlternates = entries.some((entry) => (entry.alternates?.length ?? 0) > 0);
|
|
59
|
+
const hasImages = entries.some((entry) => (entry.images?.length ?? 0) > 0);
|
|
60
|
+
const namespaces = `${hasAlternates ? XHTML_NAMESPACE_ATTR : ""}${hasImages ? IMAGE_NAMESPACE_ATTR : ""}`;
|
|
51
61
|
const body = entries.map((entry) => renderUrlBlock(entry, baseUrl)).join("\n");
|
|
52
62
|
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"${namespaces}>\n` + (body.length > 0 ? `${body}\n` : "") + `</urlset>\n`;
|
|
53
63
|
}
|
|
54
64
|
|
|
55
65
|
//#endregion
|
|
56
|
-
export { XHTML_NAMESPACE_ATTR, buildSitemapXml, escapeXml, renderUrlBlock };
|
|
66
|
+
export { IMAGE_NAMESPACE_ATTR, XHTML_NAMESPACE_ATTR, buildSitemapXml, escapeXml, renderUrlBlock };
|
|
57
67
|
//# sourceMappingURL=xml.mjs.map
|
package/esm/xml.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"xml.mjs","names":[],"sources":["../../../../../../sitemap/src/xml.ts"],"sourcesContent":["import type { ResolvedSitemapEntry } from \"./types\";\nimport { resolveAgainstBase } from \"./url\";\n\nconst XML_ESCAPES: Record<string, string> = {\n \"&\": \"&\",\n \"<\": \"<\",\n \">\": \">\",\n '\"': \""\",\n \"'\": \"'\",\n};\n\n/** Escapes the five XML-significant characters. A URL's query string routinely contains `&`. */\nexport function escapeXml(value: string): string {\n // The character class and the table are written together, so the lookup can\n // only miss if one is edited without the other; falling back to the original\n // character keeps that editing mistake from silently emitting `undefined`\n // into a URL.\n return value.replace(/[&<>\"']/g, (char) => XML_ESCAPES[char] ?? char);\n}\n\nconst XHTML_NAMESPACE = \"http://www.w3.org/1999/xhtml\";\n\n/**\n * The `xmlns:xhtml` attribute exactly as it appears on `<urlset>`, including\n * its leading space. Exported so the shard writer can charge its byte length\n * against the ceiling without duplicating the string it measures.\n */\nexport const XHTML_NAMESPACE_ATTR = ` xmlns:xhtml=\"${XHTML_NAMESPACE}\"`;\n\n/**\n * Renders one `<url>` block. Exported so the streaming writer can measure the\n * exact bytes it is about to append before deciding whether the byte ceiling\n * forces a new shard — a separate approximation could disagree with what is\n * actually written.\n */\nexport function renderUrlBlock(entry: ResolvedSitemapEntry, baseUrl: string): string {\n const lines = [` <url>`, ` <loc>${escapeXml(resolveAgainstBase(baseUrl, entry.path))}</loc>`];\n\n if (entry.lastmod !== undefined) {\n lines.push(` <lastmod>${escapeXml(entry.lastmod)}</lastmod>`);\n }\n\n if (entry.changefreq !== undefined) {\n lines.push(` <changefreq>${entry.changefreq}</changefreq>`);\n }\n\n if (entry.priority !== undefined) {\n lines.push(` <priority>${entry.priority}</priority>`);\n }\n\n for (const alternate of entry.alternates ?? []) {\n const href = escapeXml(resolveAgainstBase(baseUrl, alternate.path));\n\n lines.push(\n ` <xhtml:link rel=\"alternate\" hreflang=\"${escapeXml(alternate.hreflang)}\" href=\"${href}\"/>`,\n );\n }\n\n lines.push(` </url>`);\n\n return lines.join(\"\\n\");\n}\n\n/**\n * Serialises entries into a `urlset` sitemap document — the sitemaps.org\n * namespace, `<url>` per entry, element order `loc` / `lastmod` / `changefreq`\n * / `priority` (schema order; a validator that checks order rejects any other),\n * then any `xhtml:link` alternates.\n *\n * The xhtml namespace is declared only when some entry actually carries an\n * alternate — an unused namespace on every single-language sitemap is noise.\n */\nexport function buildSitemapXml(entries: readonly ResolvedSitemapEntry[], baseUrl: string): string {\n const hasAlternates = entries.some((entry) => (entry.alternates?.length ?? 0) > 0);\n const namespaces = hasAlternates ? XHTML_NAMESPACE_ATTR : \"\"
|
|
1
|
+
{"version":3,"file":"xml.mjs","names":[],"sources":["../../../../../../sitemap/src/xml.ts"],"sourcesContent":["import type { ResolvedSitemapEntry } from \"./types\";\nimport { resolveAgainstBase } from \"./url\";\n\nconst XML_ESCAPES: Record<string, string> = {\n \"&\": \"&\",\n \"<\": \"<\",\n \">\": \">\",\n '\"': \""\",\n \"'\": \"'\",\n};\n\n/** Escapes the five XML-significant characters. A URL's query string routinely contains `&`. */\nexport function escapeXml(value: string): string {\n // The character class and the table are written together, so the lookup can\n // only miss if one is edited without the other; falling back to the original\n // character keeps that editing mistake from silently emitting `undefined`\n // into a URL.\n return value.replace(/[&<>\"']/g, (char) => XML_ESCAPES[char] ?? char);\n}\n\nconst XHTML_NAMESPACE = \"http://www.w3.org/1999/xhtml\";\nconst IMAGE_NAMESPACE = \"http://www.google.com/schemas/sitemap-image/1.1\";\n\n/**\n * The `xmlns:xhtml` attribute exactly as it appears on `<urlset>`, including\n * its leading space. Exported so the shard writer can charge its byte length\n * against the ceiling without duplicating the string it measures.\n */\nexport const XHTML_NAMESPACE_ATTR = ` xmlns:xhtml=\"${XHTML_NAMESPACE}\"`;\n/** See {@link XHTML_NAMESPACE_ATTR}; measured by the shard writer before roll-over. */\nexport const IMAGE_NAMESPACE_ATTR = ` xmlns:image=\"${IMAGE_NAMESPACE}\"`;\n\n/**\n * Renders one `<url>` block. Exported so the streaming writer can measure the\n * exact bytes it is about to append before deciding whether the byte ceiling\n * forces a new shard — a separate approximation could disagree with what is\n * actually written.\n */\nexport function renderUrlBlock(entry: ResolvedSitemapEntry, baseUrl: string): string {\n const lines = [` <url>`, ` <loc>${escapeXml(resolveAgainstBase(baseUrl, entry.path))}</loc>`];\n\n if (entry.lastmod !== undefined) {\n lines.push(` <lastmod>${escapeXml(entry.lastmod)}</lastmod>`);\n }\n\n if (entry.changefreq !== undefined) {\n lines.push(` <changefreq>${entry.changefreq}</changefreq>`);\n }\n\n if (entry.priority !== undefined) {\n lines.push(` <priority>${entry.priority}</priority>`);\n }\n\n for (const alternate of entry.alternates ?? []) {\n const href = escapeXml(resolveAgainstBase(baseUrl, alternate.path));\n\n lines.push(\n ` <xhtml:link rel=\"alternate\" hreflang=\"${escapeXml(alternate.hreflang)}\" href=\"${href}\"/>`,\n );\n }\n\n for (const image of entry.images ?? []) {\n lines.push(` <image:image>`);\n lines.push(` <image:loc>${escapeXml(resolveAgainstBase(baseUrl, image.loc))}</image:loc>`);\n lines.push(` </image:image>`);\n }\n\n lines.push(` </url>`);\n\n return lines.join(\"\\n\");\n}\n\n/**\n * Serialises entries into a `urlset` sitemap document — the sitemaps.org\n * namespace, `<url>` per entry, element order `loc` / `lastmod` / `changefreq`\n * / `priority` (schema order; a validator that checks order rejects any other),\n * then any `xhtml:link` alternates.\n *\n * The xhtml namespace is declared only when some entry actually carries an\n * alternate — an unused namespace on every single-language sitemap is noise.\n */\nexport function buildSitemapXml(entries: readonly ResolvedSitemapEntry[], baseUrl: string): string {\n const hasAlternates = entries.some((entry) => (entry.alternates?.length ?? 0) > 0);\n const hasImages = entries.some((entry) => (entry.images?.length ?? 0) > 0);\n const namespaces = `${hasAlternates ? XHTML_NAMESPACE_ATTR : \"\"}${hasImages ? IMAGE_NAMESPACE_ATTR : \"\"}`;\n const body = entries.map((entry) => renderUrlBlock(entry, baseUrl)).join(\"\\n\");\n\n return (\n `<?xml version=\"1.0\" encoding=\"UTF-8\"?>\\n` +\n `<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\"${namespaces}>\\n` +\n (body.length > 0 ? `${body}\\n` : \"\") +\n `</urlset>\\n`\n );\n}\n"],"mappings":";;;AAGA,MAAM,cAAsC;CAC1C,KAAK;CACL,KAAK;CACL,KAAK;CACL,MAAK;CACL,KAAK;AACP;;AAGA,SAAgB,UAAU,OAAuB;CAK/C,OAAO,MAAM,QAAQ,aAAa,SAAS,YAAY,SAAS,IAAI;AACtE;AAEA,MAAM,kBAAkB;AACxB,MAAM,kBAAkB;;;;;;AAOxB,MAAa,uBAAuB,iBAAiB,gBAAgB;;AAErE,MAAa,uBAAuB,iBAAiB,gBAAgB;;;;;;;AAQrE,SAAgB,eAAe,OAA6B,SAAyB;CACnF,MAAM,QAAQ,CAAC,WAAW,YAAY,UAAU,mBAAmB,SAAS,MAAM,IAAI,CAAC,EAAE,OAAO;CAEhG,IAAI,MAAM,YAAY,QACpB,MAAM,KAAK,gBAAgB,UAAU,MAAM,OAAO,EAAE,WAAW;CAGjE,IAAI,MAAM,eAAe,QACvB,MAAM,KAAK,mBAAmB,MAAM,WAAW,cAAc;CAG/D,IAAI,MAAM,aAAa,QACrB,MAAM,KAAK,iBAAiB,MAAM,SAAS,YAAY;CAGzD,KAAK,MAAM,aAAa,MAAM,cAAc,CAAC,GAAG;EAC9C,MAAM,OAAO,UAAU,mBAAmB,SAAS,UAAU,IAAI,CAAC;EAElE,MAAM,KACJ,6CAA6C,UAAU,UAAU,QAAQ,EAAE,UAAU,KAAK,IAC5F;CACF;CAEA,KAAK,MAAM,SAAS,MAAM,UAAU,CAAC,GAAG;EACtC,MAAM,KAAK,mBAAmB;EAC9B,MAAM,KAAK,oBAAoB,UAAU,mBAAmB,SAAS,MAAM,GAAG,CAAC,EAAE,aAAa;EAC9F,MAAM,KAAK,oBAAoB;CACjC;CAEA,MAAM,KAAK,UAAU;CAErB,OAAO,MAAM,KAAK,IAAI;AACxB;;;;;;;;;;AAWA,SAAgB,gBAAgB,SAA0C,SAAyB;CACjG,MAAM,gBAAgB,QAAQ,MAAM,WAAW,MAAM,YAAY,UAAU,KAAK,CAAC;CACjF,MAAM,YAAY,QAAQ,MAAM,WAAW,MAAM,QAAQ,UAAU,KAAK,CAAC;CACzE,MAAM,aAAa,GAAG,gBAAgB,uBAAuB,KAAK,YAAY,uBAAuB;CACrG,MAAM,OAAO,QAAQ,KAAK,UAAU,eAAe,OAAO,OAAO,CAAC,CAAC,CAAC,KAAK,IAAI;CAE7E,OACE,sGAC8D,WAAW,QACxE,KAAK,SAAS,IAAI,GAAG,KAAK,MAAM,MACjC;AAEJ"}
|