@pas7/nextjs-sitemap-hreflang 0.3.1
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/LICENSE +201 -0
- package/README.md +179 -0
- package/dist/cli.js +576 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +261 -0
- package/dist/index.js +783 -0
- package/dist/index.js.map +1 -0
- package/package.json +75 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,783 @@
|
|
|
1
|
+
// src/lib/assertHreflang.ts
|
|
2
|
+
function assertHreflang(entries, options) {
|
|
3
|
+
const requireAbsolute = options.requireAbsolute ?? true;
|
|
4
|
+
const requireXDefaultWhenMultiple = options.requireXDefaultWhenMultiple ?? true;
|
|
5
|
+
const requireSelf = options.requireSelf ?? false;
|
|
6
|
+
const issues = [];
|
|
7
|
+
for (const entry of entries) {
|
|
8
|
+
const languages = entry.alternates?.languages;
|
|
9
|
+
if (!languages) continue;
|
|
10
|
+
const pairs = Object.entries(languages);
|
|
11
|
+
if (pairs.length === 0) {
|
|
12
|
+
issues.push({
|
|
13
|
+
code: "MISSING_LANGUAGES",
|
|
14
|
+
entryUrl: entry.url,
|
|
15
|
+
message: "alternates.languages is empty"
|
|
16
|
+
});
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (requireXDefaultWhenMultiple && pairs.length > 1 && !languages["x-default"]) {
|
|
20
|
+
issues.push({
|
|
21
|
+
code: "MISSING_XDEFAULT",
|
|
22
|
+
entryUrl: entry.url,
|
|
23
|
+
message: "Missing x-default hreflang for a multilingual entry"
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
if (requireSelf && options.canonicalLocale) {
|
|
27
|
+
if (!languages[options.canonicalLocale]) {
|
|
28
|
+
issues.push({
|
|
29
|
+
code: "MISSING_SELF",
|
|
30
|
+
entryUrl: entry.url,
|
|
31
|
+
message: `Missing self hreflang for canonicalLocale=${options.canonicalLocale}`
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const hrefSeen = /* @__PURE__ */ new Set();
|
|
36
|
+
for (const [locale, href] of pairs) {
|
|
37
|
+
if (!isValidLocaleKey(locale)) {
|
|
38
|
+
issues.push({
|
|
39
|
+
code: "INVALID_LOCALE_KEY",
|
|
40
|
+
entryUrl: entry.url,
|
|
41
|
+
message: `Invalid locale key: ${locale}`
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
if (requireAbsolute && !isAbsoluteUrl(href)) {
|
|
45
|
+
issues.push({
|
|
46
|
+
code: "NON_ABSOLUTE_URL",
|
|
47
|
+
entryUrl: entry.url,
|
|
48
|
+
message: `Non-absolute hreflang href for ${locale}: ${href}`
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
if (hrefSeen.has(href)) {
|
|
52
|
+
issues.push({
|
|
53
|
+
code: "DUPLICATE_HREF",
|
|
54
|
+
entryUrl: entry.url,
|
|
55
|
+
message: `Duplicate hreflang href detected: ${href}`
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
hrefSeen.add(href);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { ok: issues.length === 0, issues };
|
|
62
|
+
}
|
|
63
|
+
function isAbsoluteUrl(url) {
|
|
64
|
+
return url.startsWith("http://") || url.startsWith("https://");
|
|
65
|
+
}
|
|
66
|
+
function isValidLocaleKey(key) {
|
|
67
|
+
if (key === "x-default") return true;
|
|
68
|
+
return /^[a-z]{2}(-[A-Z]{2})?$/.test(key);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// src/lib/url.ts
|
|
72
|
+
function normalizeUrl(url, trailingSlash) {
|
|
73
|
+
const u = new URL(url);
|
|
74
|
+
if (trailingSlash === "preserve") return u.toString();
|
|
75
|
+
if (trailingSlash === "always") {
|
|
76
|
+
if (!u.pathname.endsWith("/")) u.pathname = `${u.pathname}/`;
|
|
77
|
+
return u.toString();
|
|
78
|
+
}
|
|
79
|
+
if (u.pathname !== "/" && u.pathname.endsWith("/")) u.pathname = u.pathname.slice(0, -1);
|
|
80
|
+
return u.toString();
|
|
81
|
+
}
|
|
82
|
+
function resolveAbsoluteUrl(input, baseUrl) {
|
|
83
|
+
if (input.startsWith("http://") || input.startsWith("https://")) return input;
|
|
84
|
+
return new URL(input.startsWith("/") ? input : `/${input}`, baseUrl).toString();
|
|
85
|
+
}
|
|
86
|
+
function getOriginFromAbsoluteUrl(absoluteUrl) {
|
|
87
|
+
const u = new URL(absoluteUrl);
|
|
88
|
+
return `${u.protocol}//${u.host}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// src/lib/buildLanguagesMap.ts
|
|
92
|
+
function buildLanguagesMap(input) {
|
|
93
|
+
const trailingSlash = input.trailingSlash ?? "preserve";
|
|
94
|
+
const languages = {};
|
|
95
|
+
for (const locale of input.locales) {
|
|
96
|
+
const href = input.resolveHref(locale);
|
|
97
|
+
const abs = resolveAbsoluteUrl(href, input.baseUrl);
|
|
98
|
+
languages[locale] = normalizeUrl(abs, trailingSlash);
|
|
99
|
+
}
|
|
100
|
+
const canonical = languages[input.canonicalLocale] ?? normalizeUrl(input.baseUrl, trailingSlash);
|
|
101
|
+
if (input.includeXDefault) {
|
|
102
|
+
const xDefault = resolveXDefaultForLanguages({
|
|
103
|
+
canonical,
|
|
104
|
+
languages,
|
|
105
|
+
baseUrl: input.baseUrl,
|
|
106
|
+
strategy: input.xDefaultStrategy ?? { type: "loc" },
|
|
107
|
+
trailingSlash
|
|
108
|
+
});
|
|
109
|
+
languages["x-default"] = xDefault;
|
|
110
|
+
}
|
|
111
|
+
return { canonical, languages };
|
|
112
|
+
}
|
|
113
|
+
function resolveXDefaultForLanguages(args) {
|
|
114
|
+
const { canonical, languages, baseUrl, strategy, trailingSlash } = args;
|
|
115
|
+
if (strategy.type === "loc") return normalizeUrl(canonical, trailingSlash);
|
|
116
|
+
if (strategy.type === "root") return normalizeUrl(resolveAbsoluteUrl("/", baseUrl), trailingSlash);
|
|
117
|
+
if (strategy.type === "custom") return normalizeUrl(resolveAbsoluteUrl(strategy.url, baseUrl), trailingSlash);
|
|
118
|
+
if (strategy.type === "locale") {
|
|
119
|
+
const href2 = languages[strategy.locale];
|
|
120
|
+
if (href2) return normalizeUrl(href2, trailingSlash);
|
|
121
|
+
return normalizeUrl(canonical, trailingSlash);
|
|
122
|
+
}
|
|
123
|
+
const href = strategy.resolve({ url: canonical });
|
|
124
|
+
return normalizeUrl(resolveAbsoluteUrl(href, baseUrl), trailingSlash);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// src/lib/withHreflang.ts
|
|
128
|
+
function withHreflang(entries, options) {
|
|
129
|
+
const trailingSlash = options.trailingSlash ?? "preserve";
|
|
130
|
+
const ensureAbsolute = options.ensureAbsolute ?? true;
|
|
131
|
+
const ensureXDefault = options.ensureXDefault ?? true;
|
|
132
|
+
const ensureSelf = options.ensureSelf ?? false;
|
|
133
|
+
return entries.map((entry) => {
|
|
134
|
+
if (options.shouldApply && !options.shouldApply(entry)) return entry;
|
|
135
|
+
const url = normalizeEntryUrl(entry.url, options.baseUrl, ensureAbsolute, trailingSlash);
|
|
136
|
+
const languages = entry.alternates?.languages ? normalizeLanguages(entry.alternates.languages, options.baseUrl, ensureAbsolute, trailingSlash) : void 0;
|
|
137
|
+
if (!languages) {
|
|
138
|
+
return { ...entry, url };
|
|
139
|
+
}
|
|
140
|
+
const nextLanguages = { ...languages };
|
|
141
|
+
if (ensureSelf && options.canonicalLocale) {
|
|
142
|
+
if (!nextLanguages[options.canonicalLocale]) nextLanguages[options.canonicalLocale] = url;
|
|
143
|
+
}
|
|
144
|
+
if (ensureXDefault) {
|
|
145
|
+
if (!nextLanguages["x-default"]) {
|
|
146
|
+
const xDefault = resolveXDefaultForLanguages({
|
|
147
|
+
canonical: url,
|
|
148
|
+
languages: nextLanguages,
|
|
149
|
+
baseUrl: options.baseUrl ?? url,
|
|
150
|
+
strategy: options.xDefaultStrategy ?? { type: "loc" },
|
|
151
|
+
trailingSlash
|
|
152
|
+
});
|
|
153
|
+
nextLanguages["x-default"] = xDefault;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const alternates = { ...entry.alternates ?? {}, languages: nextLanguages };
|
|
157
|
+
return { ...entry, url, alternates };
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
function normalizeEntryUrl(url, baseUrl, ensureAbsolute, trailingSlash) {
|
|
161
|
+
const abs = ensureAbsolute ? resolveAbsoluteUrl(url, baseUrl ?? url) : url;
|
|
162
|
+
return normalizeUrl(abs, trailingSlash);
|
|
163
|
+
}
|
|
164
|
+
function normalizeLanguages(languages, baseUrl, ensureAbsolute, trailingSlash) {
|
|
165
|
+
const out = {};
|
|
166
|
+
for (const [k, v] of Object.entries(languages)) {
|
|
167
|
+
const abs = ensureAbsolute ? resolveAbsoluteUrl(v, baseUrl ?? v) : v;
|
|
168
|
+
out[k] = normalizeUrl(abs, trailingSlash);
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
function assertHreflangExport(entries, options) {
|
|
173
|
+
return assertHreflang(entries, options);
|
|
174
|
+
}
|
|
175
|
+
function withHreflangFromRouting(entries, strategy, options) {
|
|
176
|
+
const trailingSlash = options.trailingSlash ?? "preserve";
|
|
177
|
+
const ensureAbsolute = options.ensureAbsolute ?? true;
|
|
178
|
+
const ensureXDefault = options.ensureXDefault ?? true;
|
|
179
|
+
const ensureSelf = options.ensureSelf ?? false;
|
|
180
|
+
return entries.map((entry) => {
|
|
181
|
+
if (options.shouldApply && !options.shouldApply(entry)) return entry;
|
|
182
|
+
let pathname;
|
|
183
|
+
try {
|
|
184
|
+
const url = new URL(entry.url, options.baseUrl);
|
|
185
|
+
pathname = url.pathname;
|
|
186
|
+
} catch {
|
|
187
|
+
pathname = entry.url;
|
|
188
|
+
}
|
|
189
|
+
const languages = {};
|
|
190
|
+
for (const locale of strategy.locales) {
|
|
191
|
+
const href = strategy.hrefFor({ pathname, locale });
|
|
192
|
+
const abs = ensureAbsolute ? resolveAbsoluteUrl(href, options.baseUrl) : href;
|
|
193
|
+
languages[locale] = normalizeUrl(abs, trailingSlash);
|
|
194
|
+
}
|
|
195
|
+
if (ensureXDefault && !languages["x-default"]) {
|
|
196
|
+
const canonical = languages[strategy.canonicalLocale] ?? normalizeUrl(entry.url, trailingSlash);
|
|
197
|
+
const xDefault = resolveXDefaultForLanguages({
|
|
198
|
+
canonical,
|
|
199
|
+
languages,
|
|
200
|
+
baseUrl: options.baseUrl,
|
|
201
|
+
strategy: strategy.xDefault ?? { type: "loc" },
|
|
202
|
+
trailingSlash
|
|
203
|
+
});
|
|
204
|
+
languages["x-default"] = xDefault;
|
|
205
|
+
}
|
|
206
|
+
if (ensureSelf && !languages[strategy.canonicalLocale]) {
|
|
207
|
+
const url = normalizeEntryUrl(entry.url, options.baseUrl, ensureAbsolute, trailingSlash);
|
|
208
|
+
languages[strategy.canonicalLocale] = url;
|
|
209
|
+
}
|
|
210
|
+
const alternates = { ...entry.alternates ?? {}, languages };
|
|
211
|
+
return { ...entry, alternates };
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// src/lib/routing.ts
|
|
216
|
+
function routingPrefixAsNeeded(options) {
|
|
217
|
+
const { defaultLocale, locales, basePath = "" } = options;
|
|
218
|
+
return {
|
|
219
|
+
locales,
|
|
220
|
+
canonicalLocale: defaultLocale,
|
|
221
|
+
hrefFor: ({ pathname, locale }) => {
|
|
222
|
+
const normalizedPath = pathname.startsWith("/") ? pathname : `/${pathname}`;
|
|
223
|
+
const base = basePath || "";
|
|
224
|
+
if (locale === defaultLocale) {
|
|
225
|
+
return `${base}${normalizedPath}`;
|
|
226
|
+
}
|
|
227
|
+
return `${base}/${locale}${normalizedPath}`;
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
function routingPrefixAlways(options) {
|
|
232
|
+
const { defaultLocale, locales, basePath = "" } = options;
|
|
233
|
+
return {
|
|
234
|
+
locales,
|
|
235
|
+
canonicalLocale: defaultLocale,
|
|
236
|
+
hrefFor: ({ pathname, locale }) => {
|
|
237
|
+
const normalizedPath = pathname.startsWith("/") ? pathname : `/${pathname}`;
|
|
238
|
+
const base = basePath || "";
|
|
239
|
+
return `${base}/${locale}${normalizedPath}`;
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
function routingDomainBased(options) {
|
|
244
|
+
const { defaultLocale, locales, localeToDomain } = options;
|
|
245
|
+
return {
|
|
246
|
+
locales,
|
|
247
|
+
canonicalLocale: defaultLocale,
|
|
248
|
+
hrefFor: ({ pathname, locale }) => {
|
|
249
|
+
const domain = localeToDomain[locale];
|
|
250
|
+
if (!domain) {
|
|
251
|
+
const defaultDomain = localeToDomain[defaultLocale] || "";
|
|
252
|
+
const normalizedPath2 = pathname.startsWith("/") ? pathname : `/${pathname}`;
|
|
253
|
+
return `${defaultDomain}/${locale}${normalizedPath2}`;
|
|
254
|
+
}
|
|
255
|
+
const normalizedPath = pathname.startsWith("/") ? pathname : `/${pathname}`;
|
|
256
|
+
return `${domain}${normalizedPath}`;
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
function routingSuffixLocale(options) {
|
|
261
|
+
const { defaultLocale, locales, basePath = "" } = options;
|
|
262
|
+
return {
|
|
263
|
+
locales,
|
|
264
|
+
canonicalLocale: defaultLocale,
|
|
265
|
+
hrefFor: ({ pathname, locale }) => {
|
|
266
|
+
const normalizedPath = pathname.startsWith("/") ? pathname : `/${pathname}`;
|
|
267
|
+
const path = normalizedPath.endsWith("/") && normalizedPath !== "/" ? normalizedPath.slice(0, -1) : normalizedPath;
|
|
268
|
+
const base = basePath || "";
|
|
269
|
+
if (locale === defaultLocale) {
|
|
270
|
+
return `${base}${path}`;
|
|
271
|
+
}
|
|
272
|
+
return `${base}${path}/${locale}`;
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
function routingCustom(options) {
|
|
277
|
+
if (options.xDefault !== void 0) {
|
|
278
|
+
return {
|
|
279
|
+
locales: options.locales,
|
|
280
|
+
canonicalLocale: options.canonicalLocale,
|
|
281
|
+
hrefFor: options.hrefFor,
|
|
282
|
+
xDefault: options.xDefault
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
locales: options.locales,
|
|
287
|
+
canonicalLocale: options.canonicalLocale,
|
|
288
|
+
hrefFor: options.hrefFor
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
function routingPAS7(options) {
|
|
292
|
+
const {
|
|
293
|
+
defaultLocale,
|
|
294
|
+
locales,
|
|
295
|
+
hubPaths = ["/blog", "/projects", "/services", "/cases"],
|
|
296
|
+
detailPathPattern = /^\/(blog|projects|services|cases)\//
|
|
297
|
+
} = options;
|
|
298
|
+
return {
|
|
299
|
+
locales,
|
|
300
|
+
canonicalLocale: defaultLocale,
|
|
301
|
+
hrefFor: ({ pathname, locale }) => {
|
|
302
|
+
const normalizedPath = pathname.startsWith("/") ? pathname : `/${pathname}`;
|
|
303
|
+
if (normalizedPath === "/" || normalizedPath === "") {
|
|
304
|
+
if (locale === defaultLocale) return "/";
|
|
305
|
+
return `/${locale}`;
|
|
306
|
+
}
|
|
307
|
+
if (detailPathPattern.test(normalizedPath)) {
|
|
308
|
+
const parts = normalizedPath.split("/").filter(Boolean);
|
|
309
|
+
if (parts.length >= 3) {
|
|
310
|
+
const section = parts[0];
|
|
311
|
+
const slug = parts.slice(2).join("/");
|
|
312
|
+
return `/${section}/${locale}/${slug}`;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const matchingHub = hubPaths.find((hub) => {
|
|
316
|
+
const normalizedHub = hub.startsWith("/") ? hub : `/${hub}`;
|
|
317
|
+
return normalizedPath === normalizedHub || normalizedPath === `${normalizedHub}/`;
|
|
318
|
+
});
|
|
319
|
+
if (matchingHub) {
|
|
320
|
+
const normalizedHub = matchingHub.startsWith("/") ? matchingHub.replace(/\/$/, "") : `/${matchingHub.replace(/\/$/, "")}`;
|
|
321
|
+
if (locale === defaultLocale) return normalizedHub;
|
|
322
|
+
return `${normalizedHub}/${locale}`;
|
|
323
|
+
}
|
|
324
|
+
if (locale === defaultLocale) return normalizedPath;
|
|
325
|
+
return `/${locale}${normalizedPath}`;
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// src/lib/expandLocales.ts
|
|
331
|
+
function expandLocaleEntries(entries, options) {
|
|
332
|
+
if (options.mode === "cluster-only") {
|
|
333
|
+
return [...entries];
|
|
334
|
+
}
|
|
335
|
+
const result = [];
|
|
336
|
+
for (const entry of entries) {
|
|
337
|
+
const languages = entry.alternates?.languages;
|
|
338
|
+
if (!languages || Object.keys(languages).length === 0) {
|
|
339
|
+
result.push(entry);
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
for (const [locale, href] of Object.entries(languages)) {
|
|
343
|
+
if (locale === "x-default" && !options.includeXDefault) continue;
|
|
344
|
+
if (options.locales && !options.locales.includes(locale)) continue;
|
|
345
|
+
const expandedEntry = {
|
|
346
|
+
...entry,
|
|
347
|
+
url: href
|
|
348
|
+
// alternates.languages remains the full cluster
|
|
349
|
+
};
|
|
350
|
+
result.push(expandedEntry);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const seen = /* @__PURE__ */ new Set();
|
|
354
|
+
return result.filter((e) => {
|
|
355
|
+
if (seen.has(e.url)) return false;
|
|
356
|
+
seen.add(e.url);
|
|
357
|
+
return true;
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// src/lib/fromManifest.ts
|
|
362
|
+
function createSitemapEntriesFromManifest(items, options) {
|
|
363
|
+
const routeStyle = options.routeStyle ?? "locale-segment";
|
|
364
|
+
const includeXDefault = options.ensureXDefault ?? true;
|
|
365
|
+
const trailingSlash = options.trailingSlash ?? "preserve";
|
|
366
|
+
const includeLocales = options.includeLocales ? new Set(options.includeLocales) : null;
|
|
367
|
+
const entries = [];
|
|
368
|
+
for (const item of items) {
|
|
369
|
+
const slug = normalizeSlug(item.slug);
|
|
370
|
+
if (!slug) continue;
|
|
371
|
+
const locales = unique(item.locales).filter((locale) => includeLocales ? includeLocales.has(locale) : true);
|
|
372
|
+
if (locales.length === 0) continue;
|
|
373
|
+
const configuredCanonical = options.canonicalLocale ?? options.defaultLocale;
|
|
374
|
+
const effectiveCanonicalLocale = locales.includes(configuredCanonical) ? configuredCanonical : locales[0] ?? configuredCanonical;
|
|
375
|
+
const languages = {};
|
|
376
|
+
for (const locale of locales) {
|
|
377
|
+
const pathname = options.pathnameFor?.({
|
|
378
|
+
slug,
|
|
379
|
+
locale,
|
|
380
|
+
defaultLocale: options.defaultLocale,
|
|
381
|
+
sectionPath: options.sectionPath,
|
|
382
|
+
routeStyle
|
|
383
|
+
}) ?? buildPathname({
|
|
384
|
+
slug,
|
|
385
|
+
locale,
|
|
386
|
+
defaultLocale: options.defaultLocale,
|
|
387
|
+
sectionPath: options.sectionPath,
|
|
388
|
+
routeStyle
|
|
389
|
+
});
|
|
390
|
+
const href = normalizeUrl(resolveAbsoluteUrl(pathname, options.baseUrl), trailingSlash);
|
|
391
|
+
languages[locale] = href;
|
|
392
|
+
}
|
|
393
|
+
const canonicalUrl = languages[effectiveCanonicalLocale] ?? normalizeUrl(resolveAbsoluteUrl("/", options.baseUrl), trailingSlash);
|
|
394
|
+
const lastModified = pickLastModified(item);
|
|
395
|
+
const baseEntry = {
|
|
396
|
+
url: canonicalUrl,
|
|
397
|
+
alternates: { languages },
|
|
398
|
+
...lastModified ? { lastModified } : {},
|
|
399
|
+
...item.changeFrequency ? { changeFrequency: item.changeFrequency } : {},
|
|
400
|
+
...typeof item.priority === "number" ? { priority: item.priority } : {},
|
|
401
|
+
...item.images ? { images: [...item.images] } : {}
|
|
402
|
+
};
|
|
403
|
+
const [entryWithHreflang] = withHreflang([baseEntry], {
|
|
404
|
+
baseUrl: options.baseUrl,
|
|
405
|
+
trailingSlash,
|
|
406
|
+
ensureAbsolute: true,
|
|
407
|
+
ensureXDefault: includeXDefault,
|
|
408
|
+
ensureSelf: true,
|
|
409
|
+
canonicalLocale: effectiveCanonicalLocale,
|
|
410
|
+
...options.xDefaultStrategy ? { xDefaultStrategy: options.xDefaultStrategy } : {}
|
|
411
|
+
});
|
|
412
|
+
if (entryWithHreflang) entries.push(entryWithHreflang);
|
|
413
|
+
}
|
|
414
|
+
return entries;
|
|
415
|
+
}
|
|
416
|
+
function buildPathname(args) {
|
|
417
|
+
const section = normalizeSection(args.sectionPath);
|
|
418
|
+
if (args.routeStyle === "prefix-as-needed") {
|
|
419
|
+
if (args.locale === args.defaultLocale) return joinPath(section, args.slug);
|
|
420
|
+
return joinPath(`/${args.locale}`, section, args.slug);
|
|
421
|
+
}
|
|
422
|
+
if (args.routeStyle === "prefix-always") {
|
|
423
|
+
return joinPath(`/${args.locale}`, section, args.slug);
|
|
424
|
+
}
|
|
425
|
+
if (args.routeStyle === "suffix-locale") {
|
|
426
|
+
if (args.locale === args.defaultLocale) return joinPath(section, args.slug);
|
|
427
|
+
return joinPath(section, args.slug, args.locale);
|
|
428
|
+
}
|
|
429
|
+
return joinPath(section, args.locale, args.slug);
|
|
430
|
+
}
|
|
431
|
+
function normalizeSlug(slug) {
|
|
432
|
+
return slug.trim().replace(/^\/+/, "").replace(/\/+$/, "");
|
|
433
|
+
}
|
|
434
|
+
function normalizeSection(sectionPath) {
|
|
435
|
+
const normalized = sectionPath.trim();
|
|
436
|
+
if (!normalized || normalized === "/") return "";
|
|
437
|
+
return `/${normalized.replace(/^\/+/, "").replace(/\/+$/, "")}`;
|
|
438
|
+
}
|
|
439
|
+
function joinPath(...parts) {
|
|
440
|
+
const cleaned = parts.map((part) => part.trim()).filter(Boolean).map((part) => part.replace(/^\/+/, "").replace(/\/+$/, ""));
|
|
441
|
+
if (cleaned.length === 0) return "/";
|
|
442
|
+
return `/${cleaned.join("/")}`;
|
|
443
|
+
}
|
|
444
|
+
function unique(values) {
|
|
445
|
+
return [...new Set(values)];
|
|
446
|
+
}
|
|
447
|
+
function pickLastModified(item) {
|
|
448
|
+
return item.lastModified ?? item.updatedAt ?? item.publishedAt ?? item.date;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// src/xml/xml.ts
|
|
452
|
+
function xmlEscape(input) {
|
|
453
|
+
return input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
454
|
+
}
|
|
455
|
+
function hasXhtmlNamespace(xml) {
|
|
456
|
+
return /<urlset[^>]*\sxmlns:xhtml="http:\/\/www\.w3\.org\/1999\/xhtml"[^>]*>/.test(xml);
|
|
457
|
+
}
|
|
458
|
+
function ensureXhtmlNamespace(xml) {
|
|
459
|
+
if (hasXhtmlNamespace(xml)) return xml;
|
|
460
|
+
return xml.replace(
|
|
461
|
+
/<urlset(\s[^>]*?)?>/m,
|
|
462
|
+
(m) => m.includes("xmlns:xhtml=") ? m : m.replace("<urlset", '<urlset xmlns:xhtml="http://www.w3.org/1999/xhtml"')
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
function extractUrlBlocks(xml) {
|
|
466
|
+
return xml.match(/<url>[\s\S]*?<\/url>/g) ?? [];
|
|
467
|
+
}
|
|
468
|
+
function extractLoc(urlBlock) {
|
|
469
|
+
return urlBlock.match(/<loc>([^<]+)<\/loc>/)?.[1]?.trim() ?? null;
|
|
470
|
+
}
|
|
471
|
+
function extractXhtmlLinks(urlBlock) {
|
|
472
|
+
const out = [];
|
|
473
|
+
const re = /<xhtml:link[^>]*\shreflang="([^"]+)"[^>]*\shref="([^"]+)"[^>]*\/>/g;
|
|
474
|
+
for (const m of urlBlock.matchAll(re)) {
|
|
475
|
+
if (m[1] !== void 0 && m[2] !== void 0) {
|
|
476
|
+
out.push({ hreflang: m[1], href: m[2] });
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return out;
|
|
480
|
+
}
|
|
481
|
+
function hasXDefault(urlBlock) {
|
|
482
|
+
return /<xhtml:link[^>]*\shreflang="x-default"[^>]*\/>/.test(urlBlock);
|
|
483
|
+
}
|
|
484
|
+
function insertXhtmlLink(urlBlock, linkXml) {
|
|
485
|
+
const lastLinkIdx = urlBlock.lastIndexOf("<xhtml:link");
|
|
486
|
+
if (lastLinkIdx === -1) {
|
|
487
|
+
const locEnd = urlBlock.indexOf("</loc>");
|
|
488
|
+
if (locEnd === -1) return urlBlock;
|
|
489
|
+
const insertPos = locEnd + "</loc>".length;
|
|
490
|
+
return `${urlBlock.slice(0, insertPos)}
|
|
491
|
+
${linkXml}${urlBlock.slice(insertPos)}`;
|
|
492
|
+
}
|
|
493
|
+
const lineEnd = urlBlock.indexOf("\n", lastLinkIdx);
|
|
494
|
+
if (lineEnd === -1) return `${urlBlock}
|
|
495
|
+
${linkXml}`;
|
|
496
|
+
return `${urlBlock.slice(0, lineEnd)}
|
|
497
|
+
${linkXml}${urlBlock.slice(lineEnd)}`;
|
|
498
|
+
}
|
|
499
|
+
function reorderXhtmlLinks(urlBlock, options) {
|
|
500
|
+
if (options.order === "preserve") return urlBlock;
|
|
501
|
+
const links = extractXhtmlLinks(urlBlock);
|
|
502
|
+
if (links.length === 0) return urlBlock;
|
|
503
|
+
const loc = extractLoc(urlBlock);
|
|
504
|
+
let canonical;
|
|
505
|
+
if (options.canonicalLocale) {
|
|
506
|
+
canonical = links.find((l) => l.hreflang === options.canonicalLocale);
|
|
507
|
+
}
|
|
508
|
+
if (!canonical && loc) {
|
|
509
|
+
canonical = links.find((l) => l.href === loc);
|
|
510
|
+
}
|
|
511
|
+
const canonicalLinks = canonical ? [canonical] : [];
|
|
512
|
+
const otherLinks = links.filter((l) => l !== canonical && l.hreflang !== "x-default");
|
|
513
|
+
const xDefaultLinks = links.filter((l) => l.hreflang === "x-default");
|
|
514
|
+
const orderedLinks = [...canonicalLinks, ...otherLinks, ...xDefaultLinks];
|
|
515
|
+
const newBlock = urlBlock.replace(/<xhtml:link[^>]*\/>\s*/g, "");
|
|
516
|
+
const locEnd = newBlock.indexOf("</loc>");
|
|
517
|
+
if (locEnd === -1) return urlBlock;
|
|
518
|
+
const insertPos = locEnd + "</loc>".length;
|
|
519
|
+
const linksXml = orderedLinks.map((l) => `
|
|
520
|
+
<xhtml:link rel="alternate" hreflang="${xmlEscape(l.hreflang)}" href="${xmlEscape(l.href)}" />`).join("");
|
|
521
|
+
return `${newBlock.slice(0, insertPos)}${linksXml}
|
|
522
|
+
${newBlock.slice(insertPos).trimStart()}`;
|
|
523
|
+
}
|
|
524
|
+
function normalizeTrailingSlashInBlock(urlBlock, policy) {
|
|
525
|
+
if (policy === "preserve") return urlBlock;
|
|
526
|
+
let result = urlBlock.replace(/<loc>([^<]+)<\/loc>/g, (_, url) => {
|
|
527
|
+
return `<loc>${applyTrailingSlashPolicy(url, policy)}</loc>`;
|
|
528
|
+
});
|
|
529
|
+
result = result.replace(
|
|
530
|
+
/(<xhtml:link[^>]*href=")([^"]+)("[^>]*\/>)/g,
|
|
531
|
+
(_, prefix, url, suffix) => {
|
|
532
|
+
return `${prefix}${applyTrailingSlashPolicy(url, policy)}${suffix}`;
|
|
533
|
+
}
|
|
534
|
+
);
|
|
535
|
+
return result;
|
|
536
|
+
}
|
|
537
|
+
function applyTrailingSlashPolicy(url, policy) {
|
|
538
|
+
try {
|
|
539
|
+
const u = new URL(url);
|
|
540
|
+
if (policy === "always" && !u.pathname.endsWith("/")) {
|
|
541
|
+
u.pathname += "/";
|
|
542
|
+
} else if (policy === "never" && u.pathname !== "/" && u.pathname.endsWith("/")) {
|
|
543
|
+
u.pathname = u.pathname.slice(0, -1);
|
|
544
|
+
}
|
|
545
|
+
return u.toString();
|
|
546
|
+
} catch {
|
|
547
|
+
return url;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// src/xml/inject.ts
|
|
552
|
+
function injectXDefaultIntoSitemapXml(xml, options) {
|
|
553
|
+
const ensureNamespace = options.ensureNamespace ?? true;
|
|
554
|
+
const xDefaultStrategy = options.xDefaultStrategy ?? { type: "loc" };
|
|
555
|
+
const order = options.order ?? "preserve";
|
|
556
|
+
const trailingSlash = options.trailingSlash ?? "preserve";
|
|
557
|
+
const xmlWithNs = ensureNamespace ? ensureXhtmlNamespace(xml) : xml;
|
|
558
|
+
const blocks = extractUrlBlocks(xmlWithNs);
|
|
559
|
+
if (blocks.length === 0) return xmlWithNs;
|
|
560
|
+
let out = xmlWithNs;
|
|
561
|
+
for (const block of blocks) {
|
|
562
|
+
const loc = extractLoc(block);
|
|
563
|
+
if (!loc) continue;
|
|
564
|
+
const links = extractXhtmlLinks(block);
|
|
565
|
+
if (links.length === 0) continue;
|
|
566
|
+
let nextBlock = block;
|
|
567
|
+
if (!hasXDefault(block)) {
|
|
568
|
+
const href = resolveXDefaultHref({
|
|
569
|
+
loc,
|
|
570
|
+
links,
|
|
571
|
+
...options.baseUrl ? { baseUrl: options.baseUrl } : {},
|
|
572
|
+
strategy: xDefaultStrategy
|
|
573
|
+
});
|
|
574
|
+
const linkXml = `<xhtml:link rel="alternate" hreflang="x-default" href="${xmlEscape(href)}" />`;
|
|
575
|
+
nextBlock = insertXhtmlLink(nextBlock, linkXml);
|
|
576
|
+
}
|
|
577
|
+
if (order === "canonical-first") {
|
|
578
|
+
nextBlock = reorderXhtmlLinks(nextBlock, {
|
|
579
|
+
...options.canonicalLocale ? { canonicalLocale: options.canonicalLocale } : {},
|
|
580
|
+
order: "canonical-first"
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
if (trailingSlash !== "preserve") {
|
|
584
|
+
nextBlock = normalizeTrailingSlashInBlock(nextBlock, trailingSlash);
|
|
585
|
+
}
|
|
586
|
+
out = out.replace(block, nextBlock);
|
|
587
|
+
}
|
|
588
|
+
return out;
|
|
589
|
+
}
|
|
590
|
+
function resolveXDefaultHref(args) {
|
|
591
|
+
const { loc, links, baseUrl, strategy } = args;
|
|
592
|
+
const locAbs = baseUrl ? resolveAbsoluteUrl(loc, baseUrl) : loc;
|
|
593
|
+
const origin = isAbsolute(locAbs) ? getOriginFromAbsoluteUrl(locAbs) : baseUrl;
|
|
594
|
+
if (strategy.type === "loc") return locAbs;
|
|
595
|
+
if (strategy.type === "root") {
|
|
596
|
+
if (!origin) return locAbs;
|
|
597
|
+
return resolveAbsoluteUrl("/", origin);
|
|
598
|
+
}
|
|
599
|
+
if (strategy.type === "custom") {
|
|
600
|
+
if (!origin) return strategy.url;
|
|
601
|
+
return resolveAbsoluteUrl(strategy.url, origin);
|
|
602
|
+
}
|
|
603
|
+
if (strategy.type === "locale") {
|
|
604
|
+
const found = links.find((l) => l.hreflang === strategy.locale)?.href;
|
|
605
|
+
if (found) return baseUrl ? resolveAbsoluteUrl(found, baseUrl) : found;
|
|
606
|
+
return locAbs;
|
|
607
|
+
}
|
|
608
|
+
const computed = strategy.resolve({ url: locAbs });
|
|
609
|
+
if (!origin) return computed;
|
|
610
|
+
return resolveAbsoluteUrl(computed, origin);
|
|
611
|
+
}
|
|
612
|
+
function isAbsolute(u) {
|
|
613
|
+
return u.startsWith("http://") || u.startsWith("https://");
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// src/xml/check.ts
|
|
617
|
+
function checkSitemapXmlHreflang(xml, options) {
|
|
618
|
+
const requireNamespace = options.requireNamespace ?? true;
|
|
619
|
+
const requireXDefaultWhenMultiple = options.requireXDefaultWhenMultiple ?? true;
|
|
620
|
+
const requireAbsolute = options.requireAbsolute ?? true;
|
|
621
|
+
const checkDuplicateKeys = options.checkDuplicateKeys ?? true;
|
|
622
|
+
const checkDuplicateHrefs = options.checkDuplicateHrefs ?? true;
|
|
623
|
+
const checkHreflangCasing = options.checkHreflangCasing ?? true;
|
|
624
|
+
const originPolicy = options.originPolicy ?? "off";
|
|
625
|
+
const issues = [];
|
|
626
|
+
const blocks = extractUrlBlocks(xml);
|
|
627
|
+
if (requireNamespace) {
|
|
628
|
+
const usesXhtml = /<xhtml:link\b/.test(xml);
|
|
629
|
+
if (usesXhtml && !hasXhtmlNamespace(xml)) {
|
|
630
|
+
issues.push({
|
|
631
|
+
code: "MISSING_LANGUAGES",
|
|
632
|
+
entryUrl: "sitemap.xml",
|
|
633
|
+
message: 'Missing xmlns:xhtml="http://www.w3.org/1999/xhtml" in <urlset>'
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
for (const block of blocks) {
|
|
638
|
+
const loc = extractLoc(block);
|
|
639
|
+
if (!loc) continue;
|
|
640
|
+
const links = extractXhtmlLinks(block);
|
|
641
|
+
if (links.length === 0) continue;
|
|
642
|
+
const hasXDefault2 = links.some((l) => l.hreflang === "x-default");
|
|
643
|
+
if (requireXDefaultWhenMultiple && links.length > 1 && !hasXDefault2) {
|
|
644
|
+
issues.push({
|
|
645
|
+
code: "MISSING_XDEFAULT",
|
|
646
|
+
entryUrl: loc,
|
|
647
|
+
message: "Missing x-default hreflang in sitemap url block"
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
if (checkDuplicateKeys) {
|
|
651
|
+
const seenKeys = /* @__PURE__ */ new Set();
|
|
652
|
+
for (const link of links) {
|
|
653
|
+
if (seenKeys.has(link.hreflang)) {
|
|
654
|
+
issues.push({
|
|
655
|
+
code: "DUPLICATE_HREFLANG_KEY",
|
|
656
|
+
entryUrl: loc,
|
|
657
|
+
message: `Duplicate hreflang key: ${link.hreflang}`
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
seenKeys.add(link.hreflang);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (checkDuplicateHrefs) {
|
|
664
|
+
const hrefToLocales = /* @__PURE__ */ new Map();
|
|
665
|
+
for (const link of links) {
|
|
666
|
+
const locales = hrefToLocales.get(link.href) ?? [];
|
|
667
|
+
locales.push(link.hreflang);
|
|
668
|
+
hrefToLocales.set(link.href, locales);
|
|
669
|
+
}
|
|
670
|
+
for (const [href, locales] of hrefToLocales) {
|
|
671
|
+
const nonXDefaultLocales = locales.filter((l) => l !== "x-default");
|
|
672
|
+
if (nonXDefaultLocales.length > 1) {
|
|
673
|
+
issues.push({
|
|
674
|
+
code: "DUPLICATE_HREF",
|
|
675
|
+
entryUrl: loc,
|
|
676
|
+
message: `Duplicate hreflang href detected: ${href} (locales: ${nonXDefaultLocales.join(", ")})`
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
if (checkHreflangCasing) {
|
|
682
|
+
for (const link of links) {
|
|
683
|
+
if (!isValidHreflangCasing(link.hreflang)) {
|
|
684
|
+
issues.push({
|
|
685
|
+
code: "INVALID_HREFLANG_CASING",
|
|
686
|
+
entryUrl: loc,
|
|
687
|
+
message: `Invalid hreflang casing: ${link.hreflang}. Expected format: en, pt-BR, or x-default`
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
if (originPolicy === "same") {
|
|
693
|
+
const locOrigin = getOrigin(loc);
|
|
694
|
+
if (locOrigin) {
|
|
695
|
+
for (const link of links) {
|
|
696
|
+
const linkOrigin = getOrigin(link.href);
|
|
697
|
+
if (linkOrigin && linkOrigin !== locOrigin) {
|
|
698
|
+
issues.push({
|
|
699
|
+
code: "INCONSISTENT_ORIGIN",
|
|
700
|
+
entryUrl: loc,
|
|
701
|
+
message: `Inconsistent origin for ${link.hreflang}: expected ${locOrigin}, got ${linkOrigin}`
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
} else if (originPolicy === "allowlist") {
|
|
707
|
+
const allowedOrigins = options.allowedOrigins ?? [];
|
|
708
|
+
for (const link of links) {
|
|
709
|
+
const linkOrigin = getOrigin(link.href);
|
|
710
|
+
if (linkOrigin && !allowedOrigins.includes(linkOrigin)) {
|
|
711
|
+
issues.push({
|
|
712
|
+
code: "INCONSISTENT_ORIGIN",
|
|
713
|
+
entryUrl: loc,
|
|
714
|
+
message: `Origin not in allowlist for ${link.hreflang}: ${linkOrigin}`
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
const locOrigin = getOrigin(loc);
|
|
719
|
+
if (locOrigin && !allowedOrigins.includes(locOrigin)) {
|
|
720
|
+
issues.push({
|
|
721
|
+
code: "INCONSISTENT_ORIGIN",
|
|
722
|
+
entryUrl: loc,
|
|
723
|
+
message: `Origin not in allowlist for loc: ${locOrigin}`
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
if (requireAbsolute) {
|
|
728
|
+
for (const link of links) {
|
|
729
|
+
if (!isAbsolute2(link.href)) {
|
|
730
|
+
issues.push({
|
|
731
|
+
code: "NON_ABSOLUTE_URL",
|
|
732
|
+
entryUrl: loc,
|
|
733
|
+
message: `Non-absolute hreflang href for ${link.hreflang}: ${link.href}`
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
if (!isAbsolute2(loc)) {
|
|
738
|
+
issues.push({
|
|
739
|
+
code: "NON_ABSOLUTE_URL",
|
|
740
|
+
entryUrl: loc,
|
|
741
|
+
message: `Non-absolute <loc>: ${loc}`
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return { ok: issues.length === 0, issues };
|
|
747
|
+
}
|
|
748
|
+
function isAbsolute2(u) {
|
|
749
|
+
return u.startsWith("http://") || u.startsWith("https://");
|
|
750
|
+
}
|
|
751
|
+
function getOrigin(url) {
|
|
752
|
+
try {
|
|
753
|
+
const u = new URL(url);
|
|
754
|
+
return u.origin;
|
|
755
|
+
} catch {
|
|
756
|
+
return null;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
function isValidHreflangCasing(key) {
|
|
760
|
+
if (key === "x-default") return true;
|
|
761
|
+
if (/^[a-z]{2}$/.test(key)) return true;
|
|
762
|
+
if (/^[a-z]{2}-[A-Z]{2}$/.test(key)) return true;
|
|
763
|
+
return false;
|
|
764
|
+
}
|
|
765
|
+
export {
|
|
766
|
+
assertHreflangExport as assertHreflang,
|
|
767
|
+
buildLanguagesMap,
|
|
768
|
+
checkSitemapXmlHreflang,
|
|
769
|
+
createSitemapEntriesFromManifest,
|
|
770
|
+
expandLocaleEntries,
|
|
771
|
+
injectXDefaultIntoSitemapXml,
|
|
772
|
+
normalizeUrl,
|
|
773
|
+
resolveAbsoluteUrl,
|
|
774
|
+
routingCustom,
|
|
775
|
+
routingDomainBased,
|
|
776
|
+
routingPAS7,
|
|
777
|
+
routingPrefixAlways,
|
|
778
|
+
routingPrefixAsNeeded,
|
|
779
|
+
routingSuffixLocale,
|
|
780
|
+
withHreflang,
|
|
781
|
+
withHreflangFromRouting
|
|
782
|
+
};
|
|
783
|
+
//# sourceMappingURL=index.js.map
|