@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.
@@ -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&amp;other=value"
137
+ }
138
+ ]
139
+
140
+ const xml = buildSitemapXml(urls)
141
+
142
+ expect(xml).toContain("&amp;")
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, "&amp;")
162
+ .replace(/</g, "&lt;")
163
+ .replace(/>/g, "&gt;")
164
+ .replace(/"/g, "&quot;")
165
+ .replace(/'/g, "&apos;")
166
+ }