@rimelight/seo 0.0.3 → 0.0.4
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/dist/index.d.mts +1 -1
- package/dist/index.mjs +7 -7
- package/dist/robots.d.mts +1 -1
- package/dist/sitemap.d.mts +1 -1
- package/dist/{types-Ck_Zs5Ws.d.mts → types-e7gqYZwc.d.mts} +4 -0
- package/dist/types.d.mts +1 -1
- package/package.json +3 -3
- package/src/components/SEOHead.astro +9 -3
- package/src/env.d.ts +3 -0
- package/src/index.ts +38 -0
- package/src/llms.test.ts +61 -0
- package/src/llms.ts +124 -0
- package/src/meta.test.ts +110 -0
- package/src/meta.ts +70 -0
- package/src/robots.test.ts +75 -0
- package/src/robots.ts +59 -0
- package/src/schema.test.ts +45 -0
- package/src/schema.ts +96 -0
- package/src/sitemap.test.ts +191 -0
- package/src/sitemap.ts +166 -0
- package/src/types.ts +242 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test"
|
|
2
|
+
import { buildArticleSchema, buildBreadcrumbSchema } from "./schema.js"
|
|
3
|
+
|
|
4
|
+
describe("seo-schema", () => {
|
|
5
|
+
it("builds TechArticle JSON-LD schema", () => {
|
|
6
|
+
const schema = buildArticleSchema({
|
|
7
|
+
type: "TechArticle",
|
|
8
|
+
title: "Building on Cloudflare Workers",
|
|
9
|
+
description: "A complete guide to edge rendering",
|
|
10
|
+
url: "https://rimelight.com/blog/cloudflare-workers",
|
|
11
|
+
datePublished: new Date("2026-08-01T12:00:00Z"),
|
|
12
|
+
authorName: "Idan",
|
|
13
|
+
image: "https://rimelight.com/og/article.png"
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
expect(schema["@context"]).toBe("https://schema.org")
|
|
17
|
+
expect(schema["@type"]).toBe("TechArticle")
|
|
18
|
+
expect(schema["headline"]).toBe("Building on Cloudflare Workers")
|
|
19
|
+
expect(schema["author"]).toEqual({ "@type": "Person", "name": "Idan" })
|
|
20
|
+
expect(schema["datePublished"]).toBe("2026-08-01T12:00:00.000Z")
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it("builds BreadcrumbList JSON-LD schema", () => {
|
|
24
|
+
const schema = buildBreadcrumbSchema([
|
|
25
|
+
{ name: "Home", url: "https://rimelight.com" },
|
|
26
|
+
{ name: "Docs", url: "https://rimelight.com/en/docs" },
|
|
27
|
+
{ name: "Installation", url: "https://rimelight.com/en/docs/installation" }
|
|
28
|
+
])
|
|
29
|
+
|
|
30
|
+
expect(schema["@type"]).toBe("BreadcrumbList")
|
|
31
|
+
expect(schema["itemListElement"]).toHaveLength(3)
|
|
32
|
+
expect(schema["itemListElement"][0]).toEqual({
|
|
33
|
+
"@type": "ListItem",
|
|
34
|
+
"position": 1,
|
|
35
|
+
"name": "Home",
|
|
36
|
+
"item": "https://rimelight.com"
|
|
37
|
+
})
|
|
38
|
+
expect(schema["itemListElement"][2]).toEqual({
|
|
39
|
+
"@type": "ListItem",
|
|
40
|
+
"position": 3,
|
|
41
|
+
"name": "Installation",
|
|
42
|
+
"item": "https://rimelight.com/en/docs/installation"
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
})
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
export interface ArticleSchemaOptions {
|
|
2
|
+
type?: "TechArticle" | "Article" | "BlogPosting"
|
|
3
|
+
title: string
|
|
4
|
+
description?: string
|
|
5
|
+
url: string
|
|
6
|
+
datePublished?: string | Date
|
|
7
|
+
dateModified?: string | Date
|
|
8
|
+
authorName?: string
|
|
9
|
+
authorUrl?: string
|
|
10
|
+
image?: string
|
|
11
|
+
publisherName?: string
|
|
12
|
+
publisherLogo?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface BreadcrumbItem {
|
|
16
|
+
name: string
|
|
17
|
+
url: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Builds Schema.org Article / TechArticle / BlogPosting JSON-LD.
|
|
22
|
+
*/
|
|
23
|
+
export function buildArticleSchema(options: ArticleSchemaOptions): Record<string, any> {
|
|
24
|
+
const {
|
|
25
|
+
type = "TechArticle",
|
|
26
|
+
title,
|
|
27
|
+
description,
|
|
28
|
+
url,
|
|
29
|
+
datePublished,
|
|
30
|
+
dateModified,
|
|
31
|
+
authorName,
|
|
32
|
+
authorUrl,
|
|
33
|
+
image,
|
|
34
|
+
publisherName = "Rimelight Entertainment",
|
|
35
|
+
publisherLogo
|
|
36
|
+
} = options
|
|
37
|
+
|
|
38
|
+
const schema: Record<string, any> = {
|
|
39
|
+
"@context": "https://schema.org",
|
|
40
|
+
"@type": type,
|
|
41
|
+
"headline": title,
|
|
42
|
+
"mainEntityOfPage": {
|
|
43
|
+
"@type": "WebPage",
|
|
44
|
+
"@id": url
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (description) schema["description"] = description
|
|
49
|
+
if (image) schema["image"] = image
|
|
50
|
+
|
|
51
|
+
if (datePublished) {
|
|
52
|
+
schema["datePublished"] =
|
|
53
|
+
datePublished instanceof Date ? datePublished.toISOString() : datePublished
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (dateModified) {
|
|
57
|
+
schema["dateModified"] =
|
|
58
|
+
dateModified instanceof Date ? dateModified.toISOString() : dateModified
|
|
59
|
+
} else if (datePublished) {
|
|
60
|
+
schema["dateModified"] = schema["datePublished"]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (authorName) {
|
|
64
|
+
schema["author"] = {
|
|
65
|
+
"@type": "Person",
|
|
66
|
+
"name": authorName,
|
|
67
|
+
...(authorUrl ? { url: authorUrl } : {})
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (publisherName) {
|
|
72
|
+
schema["publisher"] = {
|
|
73
|
+
"@type": "Organization",
|
|
74
|
+
"name": publisherName,
|
|
75
|
+
...(publisherLogo ? { logo: { "@type": "ImageObject", "url": publisherLogo } } : {})
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return schema
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Builds Schema.org BreadcrumbList JSON-LD.
|
|
84
|
+
*/
|
|
85
|
+
export function buildBreadcrumbSchema(items: BreadcrumbItem[]): Record<string, any> {
|
|
86
|
+
return {
|
|
87
|
+
"@context": "https://schema.org",
|
|
88
|
+
"@type": "BreadcrumbList",
|
|
89
|
+
"itemListElement": items.map((item, index) => ({
|
|
90
|
+
"@type": "ListItem",
|
|
91
|
+
"position": index + 1,
|
|
92
|
+
"name": item.name,
|
|
93
|
+
"item": item.url
|
|
94
|
+
}))
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, it, expect } from "vite-plus/test"
|
|
2
|
+
import {
|
|
3
|
+
buildSitemapUrls,
|
|
4
|
+
buildSitemapXml,
|
|
5
|
+
buildSitemapIndexXml,
|
|
6
|
+
chunkSitemapUrls
|
|
7
|
+
} from "./sitemap.js"
|
|
8
|
+
import type { LocaleConfig, SeoEntry } from "./types.js"
|
|
9
|
+
|
|
10
|
+
describe("buildSitemapUrls", () => {
|
|
11
|
+
const config: LocaleConfig = {
|
|
12
|
+
site: "https://example.com",
|
|
13
|
+
locales: { en: "en-US", pt: "pt-BR" },
|
|
14
|
+
defaultLocale: "en"
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
it("builds URLs for a single entry in multiple locales", () => {
|
|
18
|
+
const entries: SeoEntry[] = [{ path: "/about" }]
|
|
19
|
+
const urls = buildSitemapUrls(entries, config)
|
|
20
|
+
|
|
21
|
+
expect(urls).toHaveLength(2)
|
|
22
|
+
expect(urls[0]!.loc).toBe("https://example.com/about")
|
|
23
|
+
expect(urls[1]!.loc).toBe("https://example.com/pt/about")
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it("includes alternates for multi-locale entries", () => {
|
|
27
|
+
const entries: SeoEntry[] = [{ path: "/about" }]
|
|
28
|
+
const urls = buildSitemapUrls(entries, config)
|
|
29
|
+
|
|
30
|
+
// Default locale URL should have x-default alternate
|
|
31
|
+
const defaultUrl = urls.find((u) => u.loc === "https://example.com/about")
|
|
32
|
+
expect(defaultUrl?.alternates).toHaveLength(2)
|
|
33
|
+
expect(defaultUrl?.alternates?.[0]!.hreflang).toBe("pt-BR")
|
|
34
|
+
expect(defaultUrl?.alternates?.[0]!.href).toBe("https://example.com/pt/about")
|
|
35
|
+
expect(defaultUrl?.alternates?.[1]!.hreflang).toBe("x-default")
|
|
36
|
+
expect(defaultUrl?.alternates?.[1]!.href).toBe("https://example.com/about")
|
|
37
|
+
|
|
38
|
+
// Non-default locale URL should have only the other locale as alternate
|
|
39
|
+
const ptUrl = urls.find((u) => u.loc === "https://example.com/pt/about")
|
|
40
|
+
expect(ptUrl?.alternates).toHaveLength(1)
|
|
41
|
+
expect(ptUrl?.alternates?.[0]!.hreflang).toBe("en-US")
|
|
42
|
+
expect(ptUrl?.alternates?.[0]!.href).toBe("https://example.com/about")
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("handles prefixDefaultLocale option", () => {
|
|
46
|
+
const configWithPrefix: LocaleConfig = {
|
|
47
|
+
...config,
|
|
48
|
+
prefixDefaultLocale: true
|
|
49
|
+
}
|
|
50
|
+
const entries: SeoEntry[] = [{ path: "/about" }]
|
|
51
|
+
const urls = buildSitemapUrls(entries, configWithPrefix)
|
|
52
|
+
|
|
53
|
+
expect(urls[0]!.loc).toBe("https://example.com/en/about")
|
|
54
|
+
expect(urls[1]!.loc).toBe("https://example.com/pt/about")
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("handles trailingSlash option", () => {
|
|
58
|
+
const configWithSlash: LocaleConfig = {
|
|
59
|
+
...config,
|
|
60
|
+
trailingSlash: "always"
|
|
61
|
+
}
|
|
62
|
+
const entries: SeoEntry[] = [{ path: "/about" }]
|
|
63
|
+
const urls = buildSitemapUrls(entries, configWithSlash)
|
|
64
|
+
|
|
65
|
+
expect(urls[0]!.loc).toBe("https://example.com/about/")
|
|
66
|
+
expect(urls[1]!.loc).toBe("https://example.com/pt/about/")
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("includes lastmod when provided", () => {
|
|
70
|
+
const date = new Date("2024-01-01")
|
|
71
|
+
const entries: SeoEntry[] = [{ path: "/about", lastmod: date }]
|
|
72
|
+
const urls = buildSitemapUrls(entries, config)
|
|
73
|
+
|
|
74
|
+
expect(urls[0]!.lastmod).toBe(date.toISOString())
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("includes changefreq and priority when provided", () => {
|
|
78
|
+
const entries: SeoEntry[] = [{ path: "/about", changefreq: "weekly", priority: 0.8 }]
|
|
79
|
+
const urls = buildSitemapUrls(entries, config)
|
|
80
|
+
|
|
81
|
+
expect(urls[0]!.changefreq).toBe("weekly")
|
|
82
|
+
expect(urls[0]!.priority).toBe(0.8)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("restricts to specified locales", () => {
|
|
86
|
+
const entries: SeoEntry[] = [{ path: "/about", locales: ["en"] }]
|
|
87
|
+
const urls = buildSitemapUrls(entries, config)
|
|
88
|
+
|
|
89
|
+
expect(urls).toHaveLength(1)
|
|
90
|
+
expect(urls[0]!.loc).toBe("https://example.com/about")
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe("buildSitemapXml", () => {
|
|
95
|
+
it("generates valid XML for URLs", () => {
|
|
96
|
+
const urls = [
|
|
97
|
+
{
|
|
98
|
+
loc: "https://example.com/about",
|
|
99
|
+
lastmod: "2024-01-01T00:00:00.000Z",
|
|
100
|
+
changefreq: "weekly" as const,
|
|
101
|
+
priority: 0.8
|
|
102
|
+
}
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
const xml = buildSitemapXml(urls)
|
|
106
|
+
|
|
107
|
+
expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>')
|
|
108
|
+
expect(xml).toContain("<urlset")
|
|
109
|
+
expect(xml).toContain("https://example.com/about")
|
|
110
|
+
expect(xml).toContain("<lastmod>2024-01-01T00:00:00.000Z</lastmod>")
|
|
111
|
+
expect(xml).toContain("<changefreq>weekly</changefreq>")
|
|
112
|
+
expect(xml).toContain("<priority>0.8</priority>")
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it("includes alternates in XML", () => {
|
|
116
|
+
const urls = [
|
|
117
|
+
{
|
|
118
|
+
loc: "https://example.com/about",
|
|
119
|
+
alternates: [
|
|
120
|
+
{ hreflang: "pt-BR", href: "https://example.com/pt/about" },
|
|
121
|
+
{ hreflang: "x-default", href: "https://example.com/about" }
|
|
122
|
+
]
|
|
123
|
+
}
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
const xml = buildSitemapXml(urls)
|
|
127
|
+
|
|
128
|
+
expect(xml).toContain("xhtml:link")
|
|
129
|
+
expect(xml).toContain('hreflang="pt-BR"')
|
|
130
|
+
expect(xml).toContain('hreflang="x-default"')
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it("escapes XML special characters", () => {
|
|
134
|
+
const urls = [
|
|
135
|
+
{
|
|
136
|
+
loc: "https://example.com/about?query=test&other=value"
|
|
137
|
+
}
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
const xml = buildSitemapXml(urls)
|
|
141
|
+
|
|
142
|
+
expect(xml).toContain("&")
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
describe("buildSitemapIndexXml", () => {
|
|
147
|
+
it("generates valid sitemap index XML", () => {
|
|
148
|
+
const sitemaps = ["https://example.com/sitemap-0.xml", "https://example.com/sitemap-1.xml"]
|
|
149
|
+
|
|
150
|
+
const xml = buildSitemapIndexXml(sitemaps)
|
|
151
|
+
|
|
152
|
+
expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>')
|
|
153
|
+
expect(xml).toContain("<sitemapindex")
|
|
154
|
+
expect(xml).toContain("https://example.com/sitemap-0.xml")
|
|
155
|
+
expect(xml).toContain("https://example.com/sitemap-1.xml")
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
describe("chunkSitemapUrls", () => {
|
|
160
|
+
it("chunks URLs at default limit", () => {
|
|
161
|
+
const urls = Array.from({ length: 50000 }, (_, i) => ({
|
|
162
|
+
loc: `https://example.com/page-${i}`
|
|
163
|
+
}))
|
|
164
|
+
|
|
165
|
+
const chunks = chunkSitemapUrls(urls)
|
|
166
|
+
|
|
167
|
+
expect(chunks).toHaveLength(2)
|
|
168
|
+
expect(chunks[0]).toHaveLength(45000)
|
|
169
|
+
expect(chunks[1]).toHaveLength(5000)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it("handles custom limit", () => {
|
|
173
|
+
const urls = Array.from({ length: 100 }, (_, i) => ({
|
|
174
|
+
loc: `https://example.com/page-${i}`
|
|
175
|
+
}))
|
|
176
|
+
|
|
177
|
+
const chunks = chunkSitemapUrls(urls, 25)
|
|
178
|
+
|
|
179
|
+
expect(chunks).toHaveLength(4)
|
|
180
|
+
expect(chunks[0]).toHaveLength(25)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it("returns single chunk for small arrays", () => {
|
|
184
|
+
const urls = [{ loc: "https://example.com/page-1" }, { loc: "https://example.com/page-2" }]
|
|
185
|
+
|
|
186
|
+
const chunks = chunkSitemapUrls(urls)
|
|
187
|
+
|
|
188
|
+
expect(chunks).toHaveLength(1)
|
|
189
|
+
expect(chunks[0]).toHaveLength(2)
|
|
190
|
+
})
|
|
191
|
+
})
|
package/src/sitemap.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type { LocaleConfig, SeoEntry, SitemapUrl } from "./types.js"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build fully-resolved sitemap URLs from entries and locale config
|
|
5
|
+
*/
|
|
6
|
+
export function buildSitemapUrls(entries: SeoEntry[], config: LocaleConfig): SitemapUrl[] {
|
|
7
|
+
const urls: SitemapUrl[] = []
|
|
8
|
+
const { site, locales, defaultLocale, prefixDefaultLocale, trailingSlash } = config
|
|
9
|
+
|
|
10
|
+
for (const entry of entries) {
|
|
11
|
+
const entryLocales =
|
|
12
|
+
entry.locales && entry.locales.length > 0 ? entry.locales : Object.keys(locales)
|
|
13
|
+
|
|
14
|
+
const entryUrls: SitemapUrl[] = []
|
|
15
|
+
|
|
16
|
+
for (const locale of entryLocales) {
|
|
17
|
+
const isDefault = locale === defaultLocale
|
|
18
|
+
const localePrefix = !isDefault || prefixDefaultLocale ? `/${locale}` : ""
|
|
19
|
+
|
|
20
|
+
let path = entry.path
|
|
21
|
+
if (trailingSlash === "always" && !path.endsWith("/")) {
|
|
22
|
+
path += "/"
|
|
23
|
+
} else if (trailingSlash === "never" && path.endsWith("/")) {
|
|
24
|
+
path = path.replace(/\/$/, "")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const loc = `${site}${localePrefix}${path}`
|
|
28
|
+
|
|
29
|
+
const item: SitemapUrl = { loc }
|
|
30
|
+
if (entry.lastmod) {
|
|
31
|
+
item.lastmod = entry.lastmod.toISOString()
|
|
32
|
+
}
|
|
33
|
+
if (entry.changefreq) {
|
|
34
|
+
item.changefreq = entry.changefreq
|
|
35
|
+
}
|
|
36
|
+
if (entry.priority !== undefined) {
|
|
37
|
+
item.priority = entry.priority
|
|
38
|
+
}
|
|
39
|
+
entryUrls.push(item)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Add alternates if multiple locales
|
|
43
|
+
if (entryUrls.length > 1) {
|
|
44
|
+
const defaultUrl = entryUrls.find(
|
|
45
|
+
(u) => u.loc === `${site}${prefixDefaultLocale ? `/${defaultLocale}` : ""}${entry.path}`
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
for (const url of entryUrls) {
|
|
49
|
+
url.alternates = entryUrls
|
|
50
|
+
.filter((u) => u.loc !== url.loc)
|
|
51
|
+
.map((u) => ({
|
|
52
|
+
hreflang: extractHreflang(u.loc, locales, defaultLocale),
|
|
53
|
+
href: u.loc
|
|
54
|
+
}))
|
|
55
|
+
|
|
56
|
+
// Add x-default pointing to default locale
|
|
57
|
+
// x-default should be on the default URL itself, pointing to itself
|
|
58
|
+
if (defaultUrl && url.loc === defaultUrl.loc) {
|
|
59
|
+
url.alternates.push({
|
|
60
|
+
hreflang: "x-default",
|
|
61
|
+
href: defaultUrl.loc
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
urls.push(...entryUrls)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return urls
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build sitemap XML from resolved URLs
|
|
75
|
+
*/
|
|
76
|
+
export function buildSitemapXml(urls: SitemapUrl[]): string {
|
|
77
|
+
const urlElements = urls
|
|
78
|
+
.map((url) => {
|
|
79
|
+
let xml = ` <url>\n <loc>${escapeXml(url.loc)}</loc>`
|
|
80
|
+
|
|
81
|
+
if (url.lastmod) {
|
|
82
|
+
xml += `\n <lastmod>${url.lastmod}</lastmod>`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (url.changefreq) {
|
|
86
|
+
xml += `\n <changefreq>${url.changefreq}</changefreq>`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (url.priority !== undefined) {
|
|
90
|
+
xml += `\n <priority>${url.priority.toFixed(1)}</priority>`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (url.alternates && url.alternates.length > 0) {
|
|
94
|
+
for (const alt of url.alternates) {
|
|
95
|
+
xml += `\n <xhtml:link rel="alternate" hreflang="${alt.hreflang}" href="${escapeXml(alt.href)}" />`
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
xml += "\n </url>"
|
|
100
|
+
return xml
|
|
101
|
+
})
|
|
102
|
+
.join("\n")
|
|
103
|
+
|
|
104
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
105
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
|
106
|
+
${urlElements}
|
|
107
|
+
</urlset>`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Build sitemap index XML from sitemap URLs
|
|
112
|
+
*/
|
|
113
|
+
export function buildSitemapIndexXml(sitemapUrls: string[]): string {
|
|
114
|
+
const sitemapElements = sitemapUrls
|
|
115
|
+
.map(
|
|
116
|
+
(url) => ` <sitemap>
|
|
117
|
+
<loc>${escapeXml(url)}</loc>
|
|
118
|
+
</sitemap>`
|
|
119
|
+
)
|
|
120
|
+
.join("\n")
|
|
121
|
+
|
|
122
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
123
|
+
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
124
|
+
${sitemapElements}
|
|
125
|
+
</sitemapindex>`
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Chunk sitemap URLs into multiple sitemaps (max 50k URLs per sitemap)
|
|
130
|
+
*/
|
|
131
|
+
export function chunkSitemapUrls(urls: SitemapUrl[], limit: number = 45000): SitemapUrl[][] {
|
|
132
|
+
const chunks: SitemapUrl[][] = []
|
|
133
|
+
for (let i = 0; i < urls.length; i += limit) {
|
|
134
|
+
chunks.push(urls.slice(i, i + limit))
|
|
135
|
+
}
|
|
136
|
+
return chunks
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Extract hreflang from a URL based on locale config
|
|
141
|
+
*/
|
|
142
|
+
function extractHreflang(
|
|
143
|
+
url: string,
|
|
144
|
+
locales: Record<string, string>,
|
|
145
|
+
defaultLocale: string
|
|
146
|
+
): string {
|
|
147
|
+
for (const [segment, hreflang] of Object.entries(locales)) {
|
|
148
|
+
if (url.includes(`/${segment}/`) || url.endsWith(`/${segment}`)) {
|
|
149
|
+
return hreflang
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// Default to the first locale's hreflang if no match
|
|
153
|
+
return locales[defaultLocale] || "en"
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Escape XML special characters
|
|
158
|
+
*/
|
|
159
|
+
function escapeXml(str: string): string {
|
|
160
|
+
return str
|
|
161
|
+
.replace(/&/g, "&")
|
|
162
|
+
.replace(/</g, "<")
|
|
163
|
+
.replace(/>/g, ">")
|
|
164
|
+
.replace(/"/g, """)
|
|
165
|
+
.replace(/'/g, "'")
|
|
166
|
+
}
|