@rimelight/seo 0.0.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/README.md ADDED
@@ -0,0 +1,19 @@
1
+ # Rimelight Entertainment Workspace
2
+
3
+ ## Structure
4
+
5
+ ### Apps (`/packages`)
6
+
7
+ - **`rimelight.com`**: The main company website.
8
+ - **`starter.rimelight.com`**: Our standardized starter template for Astro websites.
9
+
10
+ ### Packages (`/packages`)
11
+
12
+ - **`@rimelight/auth`**: Authentication and authorization utilities for the Rimelight ecosystem.
13
+ - **`@rimelight/cli`**: The command line interface for managing Rimelight projects.
14
+ - **`@rimelight/cms`**: Enterprise content management, block rendering, and wiki engine.
15
+ - **`@rimelight/docs`**: Documentation components and utilities.
16
+ - **`@rimelight/i18n`**: Internationalization and localization tools.
17
+ - **`@rimelight/security`**: Astro security integration (CSP, SRI, and more).
18
+ - **`@rimelight/seo`**: SEO utilities including sitemap, robots, and meta components.
19
+ - **`@rimelight/ui`**: Our component library used in all our web projects.
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@rimelight/seo",
3
+ "version": "0.0.1",
4
+ "private": false,
5
+ "description": "Rimelight Entertainment's SEO Package",
6
+ "homepage": "https://rimelight.com/docs",
7
+ "bugs": {
8
+ "url": "https://github.com/Rimelight-Entertainment/rimelight/issues"
9
+ },
10
+ "license": "MIT",
11
+ "author": {
12
+ "name": "Rimelight Entertainment"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/Rimelight-Entertainment/rimelight.git"
17
+ },
18
+ "files": [
19
+ "src"
20
+ ],
21
+ "type": "module",
22
+ "exports": {
23
+ ".": "./src/index.ts",
24
+ "./types": "./src/types.ts",
25
+ "./sitemap": "./src/sitemap.ts",
26
+ "./robots": "./src/robots.ts",
27
+ "./components/*": "./src/components/*",
28
+ "./*": "./src/*"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "scripts": {
34
+ "check": "pnpm audit --audit-level=moderate && vp check --fix && astro check"
35
+ },
36
+ "devDependencies": {
37
+ "@rimelight/config": "workspace:*",
38
+ "astro": "7.2.2",
39
+ "typescript": "6.0.3"
40
+ },
41
+ "peerDependencies": {
42
+ "astro": ">=7.0.0"
43
+ },
44
+ "engines": {
45
+ "node": ">=26.7.0"
46
+ },
47
+ "packageManager": "pnpm@11.22.0"
48
+ }
@@ -0,0 +1,124 @@
1
+ ---
2
+ import type { HeadProps } from "../types.js"
3
+ import {
4
+ buildCanonicalUrl,
5
+ buildPageTitle,
6
+ buildSafeDescription,
7
+ buildRobotsMetaContent,
8
+ buildWebSiteSchema
9
+ } from "../meta.js"
10
+
11
+ const {
12
+ title,
13
+ description,
14
+ ogImage,
15
+ ogType = 'website',
16
+ ogLocale = 'en_US',
17
+ noindex = false,
18
+ is404 = false,
19
+ robots,
20
+ canonical,
21
+ languageAlternates = [],
22
+ config,
23
+ charset = 'utf-8',
24
+ viewport = 'width=device-width, initial-scale=1',
25
+ rssFeed,
26
+ } = Astro.props as HeadProps
27
+
28
+ // Resolve defaults from config if available
29
+ const siteName = config?.name || 'Rimelight'
30
+ const siteDescription = config?.description || ''
31
+ const ogImageFallback = config?.seo?.ogImageFallback || config?.ogImage || ''
32
+ const themeColor = config?.branding?.colors?.themeColor
33
+ const favicon = config?.branding?.favicon?.svg || '/favicon.svg'
34
+ const twitterHandle = config?.seo?.authorHandle
35
+ const titleTemplate = config?.seo?.titleTemplate || `%s | ${siteName}`
36
+
37
+ // Calculated Values
38
+ const displayTitle = buildPageTitle({
39
+ ...(title !== undefined && { title }),
40
+ siteName,
41
+ titleTemplate,
42
+ ...(is404 !== undefined && { is404 })
43
+ })
44
+
45
+ const finalDescription = description || siteDescription
46
+ const safeDescription = config?.seo?.maxDescriptionLength
47
+ ? buildSafeDescription(finalDescription, config.seo.maxDescriptionLength)
48
+ : finalDescription
49
+
50
+ const finalOgImage = ogImage || (ogImageFallback ? { src: ogImageFallback, alt: siteName } : undefined)
51
+ const absoluteOgImage = finalOgImage
52
+ ? new URL(finalOgImage.src, Astro.site ?? Astro.url).toString()
53
+ : undefined
54
+
55
+ const finalCanonical = canonical
56
+ ? buildCanonicalUrl(canonical, Astro.site ?? Astro.url)
57
+ : (!is404 ? buildCanonicalUrl(Astro.url, Astro.site ?? Astro.url) : undefined)
58
+
59
+ const robotsContent = buildRobotsMetaContent({
60
+ noindex,
61
+ is404,
62
+ ...(robots !== undefined && { override: robots })
63
+ })
64
+ ---
65
+
66
+ <!-- Basic Metadata -->
67
+ <meta charset={charset} />
68
+ <meta name="viewport" content={viewport} />
69
+ <meta name="generator" content={Astro.generator} />
70
+
71
+ <!-- Sitemap -->
72
+ <link rel="sitemap" href="/sitemap.xml" />
73
+
74
+ <!-- RSS Feed -->
75
+ {rssFeed && <link rel="alternate" type="application/rss+xml" href={rssFeed.href} title={rssFeed.title || 'RSS'} />}
76
+
77
+ <!-- SEO -->
78
+ <title>{displayTitle}</title>
79
+ {safeDescription && <meta name="description" content={safeDescription} />}
80
+ <meta name="robots" content={robotsContent} />
81
+ {finalCanonical && <link rel="canonical" href={finalCanonical} />}
82
+
83
+ <!-- i18n Alternates -->
84
+ {languageAlternates.map((alt) => (
85
+ <link rel="alternate" hreflang={alt.hreflang} href={alt.href} />
86
+ ))}
87
+
88
+ <!-- Open Graph / Facebook -->
89
+ <meta property="og:type" content={ogType} />
90
+ <meta property="og:locale" content={ogLocale} />
91
+ <meta property="og:url" content={Astro.url.href} />
92
+ <meta property="og:title" content={title || siteName} />
93
+ {safeDescription && <meta property="og:description" content={safeDescription} />}
94
+ {absoluteOgImage && <meta property="og:image" content={absoluteOgImage} />}
95
+ {finalOgImage && <meta property="og:image:alt" content={finalOgImage.alt} />}
96
+ <meta property="og:site_name" content={siteName} />
97
+
98
+ <!-- Twitter -->
99
+ <meta property="twitter:card" content="summary_large_image" />
100
+ <meta property="twitter:url" content={Astro.url.href} />
101
+ <meta property="twitter:title" content={title || siteName} />
102
+ {safeDescription && <meta property="twitter:description" content={safeDescription} />}
103
+ {absoluteOgImage && <meta property="twitter:image" content={absoluteOgImage} />}
104
+ {finalOgImage && <meta property="twitter:image:alt" content={finalOgImage.alt} />}
105
+ {twitterHandle && <meta property="twitter:site" content={twitterHandle} />}
106
+ {twitterHandle && <meta property="twitter:creator" content={twitterHandle} />}
107
+
108
+ <!-- Icons & Manifest -->
109
+ <link rel="icon" type="image/svg+xml" href={favicon} />
110
+ <link rel="icon" href="/favicon.ico" sizes="32x32" />
111
+ <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
112
+ <link rel="manifest" href="/site.webmanifest" />
113
+
114
+ <!-- Theme Metadata -->
115
+ <meta name="color-scheme" content="light dark" />
116
+ <meta name="theme-color" media="(prefers-color-scheme: light)" content={themeColor || "#ffffff"} />
117
+ <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0a0a0a" />
118
+
119
+ <!-- Structured Data -->
120
+ {config && (
121
+ <script type="application/ld+json" is:inline set:html={JSON.stringify(
122
+ buildWebSiteSchema(config, Astro.site?.toString())
123
+ )} />
124
+ )}
package/src/env.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ declare module "*.astro" {
2
+ export default {} as import("astro/runtime/server").AstroComponentFactory
3
+ }
package/src/index.ts ADDED
@@ -0,0 +1,30 @@
1
+ export type {
2
+ ChangeFreq,
3
+ LocaleConfig,
4
+ RobotsOptions,
5
+ SeoEntry,
6
+ SitemapAlternate,
7
+ SitemapUrl,
8
+ UserAgentRule,
9
+ SiteConfig,
10
+ OGImage,
11
+ RSSFeed,
12
+ HeadProps
13
+ } from "./types.js"
14
+
15
+ export {
16
+ buildSitemapUrls,
17
+ buildSitemapXml,
18
+ buildSitemapIndexXml,
19
+ chunkSitemapUrls
20
+ } from "./sitemap.js"
21
+
22
+ export { buildRobotsTxt } from "./robots.js"
23
+
24
+ export {
25
+ buildCanonicalUrl,
26
+ buildPageTitle,
27
+ buildSafeDescription,
28
+ buildRobotsMetaContent,
29
+ buildWebSiteSchema
30
+ } from "./meta.js"
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect } from "vite-plus/test"
2
+ import {
3
+ buildCanonicalUrl,
4
+ buildPageTitle,
5
+ buildSafeDescription,
6
+ buildRobotsMetaContent,
7
+ buildWebSiteSchema
8
+ } from "./meta.js"
9
+
10
+ const BASE = "https://example.com"
11
+
12
+ describe("buildCanonicalUrl", () => {
13
+ it("adds trailing slash to clean paths", () => {
14
+ expect(buildCanonicalUrl("/about", BASE)).toBe("https://example.com/about/")
15
+ })
16
+
17
+ it("strips trailing slash from query-param paths", () => {
18
+ expect(buildCanonicalUrl("/search?q=hello", BASE)).toBe("https://example.com/search?q=hello")
19
+ })
20
+
21
+ it("handles root path", () => {
22
+ expect(buildCanonicalUrl("/", BASE)).toBe("https://example.com/")
23
+ })
24
+
25
+ it("resolves relative paths against base", () => {
26
+ expect(buildCanonicalUrl(new URL("https://example.com/docs"), BASE)).toBe(
27
+ "https://example.com/docs/"
28
+ )
29
+ })
30
+ })
31
+
32
+ describe("buildPageTitle", () => {
33
+ const opts = { siteName: "My Site", titleTemplate: "%s | My Site" }
34
+
35
+ it("applies title template", () => {
36
+ expect(buildPageTitle({ ...opts, title: "About" })).toBe("About | My Site")
37
+ })
38
+
39
+ it("returns siteName when no title", () => {
40
+ expect(buildPageTitle(opts)).toBe("My Site")
41
+ })
42
+
43
+ it("returns 404 label for error pages", () => {
44
+ expect(buildPageTitle({ ...opts, is404: true })).toBe("404 - Not Found | My Site")
45
+ })
46
+
47
+ it("404 overrides title if both provided", () => {
48
+ expect(buildPageTitle({ ...opts, title: "Oops", is404: true })).toBe(
49
+ "404 - Not Found | My Site"
50
+ )
51
+ })
52
+ })
53
+
54
+ describe("buildSafeDescription", () => {
55
+ it("returns description unchanged when within limit", () => {
56
+ expect(buildSafeDescription("Short desc", 160)).toBe("Short desc")
57
+ })
58
+
59
+ it("truncates and appends ellipsis when over limit", () => {
60
+ const long = "a".repeat(200)
61
+ const result = buildSafeDescription(long, 160)
62
+ expect(result).toHaveLength(160)
63
+ expect(result.endsWith("...")).toBe(true)
64
+ })
65
+
66
+ it("handles empty string", () => {
67
+ expect(buildSafeDescription("", 160)).toBe("")
68
+ })
69
+
70
+ it("truncates at exact boundary", () => {
71
+ const text = "a".repeat(160)
72
+ expect(buildSafeDescription(text, 160)).toBe(text)
73
+ expect(buildSafeDescription(text + "b", 160)).toHaveLength(160)
74
+ })
75
+ })
76
+
77
+ describe("buildRobotsMetaContent", () => {
78
+ it("returns indexable string by default", () => {
79
+ expect(buildRobotsMetaContent({})).toBe("index, follow, max-image-preview:large")
80
+ })
81
+
82
+ it("returns noindex for noindex=true", () => {
83
+ expect(buildRobotsMetaContent({ noindex: true })).toBe("noindex, nofollow")
84
+ })
85
+
86
+ it("returns noindex for is404=true", () => {
87
+ expect(buildRobotsMetaContent({ is404: true })).toBe("noindex, nofollow")
88
+ })
89
+
90
+ it("respects override", () => {
91
+ expect(buildRobotsMetaContent({ override: "noarchive" })).toBe("noarchive")
92
+ })
93
+ })
94
+
95
+ describe("buildWebSiteSchema", () => {
96
+ const config = { name: "My Site", url: "https://example.com", description: "A test site" }
97
+
98
+ it("builds schema.org WebSite object", () => {
99
+ const schema = buildWebSiteSchema(config)
100
+ expect(schema["@context"]).toBe("https://schema.org")
101
+ expect(schema["@type"]).toBe("WebSite")
102
+ expect(schema.name).toBe("My Site")
103
+ expect(schema.url).toBe("https://example.com")
104
+ })
105
+
106
+ it("prefers site param over config.url", () => {
107
+ const schema = buildWebSiteSchema(config, "https://override.com")
108
+ expect(schema.url).toBe("https://override.com")
109
+ })
110
+ })
package/src/meta.ts ADDED
@@ -0,0 +1,70 @@
1
+ import type { SiteConfig } from "./types.js"
2
+
3
+ /**
4
+ * Build a canonical URL from a page URL and site base. - Strips trailing slash unless the path has
5
+ * query params (where trailing slash is removed too). - Returns undefined-safe: pass Astro.url and
6
+ * Astro.site directly.
7
+ */
8
+ export function buildCanonicalUrl(url: string | URL, base: string | URL): string {
9
+ const resolved = new URL(url, base)
10
+ const path = resolved.toString()
11
+ // Keep query params as-is but strip trailing slash; for clean paths also ensure trailing slash
12
+ return path.includes("?") ? path.replace(/\/?$/, "") : path.replace(/\/?$/, "/")
13
+ }
14
+
15
+ /**
16
+ * Build a page title using a site's title template. Falls back to siteName for untitled pages; uses
17
+ * a 404 label for error pages.
18
+ */
19
+ export function buildPageTitle(opts: {
20
+ title?: string
21
+ siteName: string
22
+ titleTemplate: string
23
+ is404?: boolean
24
+ }): string {
25
+ const { title, siteName, titleTemplate, is404 } = opts
26
+ if (is404) return `404 - Not Found | ${siteName}`
27
+ if (!title) return siteName
28
+ return titleTemplate.replace("%s", title)
29
+ }
30
+
31
+ /**
32
+ * Truncate a description to a maximum character length, appending "…" if trimmed.
33
+ */
34
+ export function buildSafeDescription(description: string, maxLength: number): string {
35
+ if (!description) return description
36
+ if (description.length <= maxLength) return description
37
+ return description.slice(0, maxLength - 3) + "..."
38
+ }
39
+
40
+ /**
41
+ * Build the value for the <meta name="robots"> tag. Returns the full directive string including
42
+ * max-image-preview for indexable pages.
43
+ */
44
+ export function buildRobotsMetaContent(opts: {
45
+ noindex?: boolean
46
+ is404?: boolean
47
+ override?: string
48
+ }): string {
49
+ if (opts.override) return opts.override
50
+ return !opts.noindex && !opts.is404
51
+ ? "index, follow, max-image-preview:large"
52
+ : "noindex, nofollow"
53
+ }
54
+
55
+ /**
56
+ * \n * Build a Schema.org WebSite JSON-LD object.\n * Pass the result to JSON.stringify and emit
57
+ * via <script type="application/ld+json">.\n
58
+ */
59
+ export function buildWebSiteSchema(
60
+ config: Pick<SiteConfig, "name" | "url" | "description">,
61
+ site?: string
62
+ ): Record<string, unknown> {
63
+ return {
64
+ "@context": "https://schema.org",
65
+ "@type": "WebSite",
66
+ "name": config.name,
67
+ "url": site ?? config.url,
68
+ "description": config.description
69
+ }
70
+ }
@@ -0,0 +1,75 @@
1
+ import { describe, it, expect } from "vite-plus/test"
2
+ import { buildRobotsTxt } from "./robots.js"
3
+ import type { RobotsOptions } from "./types.js"
4
+
5
+ describe("buildRobotsTxt", () => {
6
+ it("generates basic robots.txt with sitemap", () => {
7
+ const options: RobotsOptions = {
8
+ sitemapUrl: "https://example.com/sitemap.xml"
9
+ }
10
+
11
+ const robots = buildRobotsTxt(options)
12
+
13
+ expect(robots).toContain("User-agent: *")
14
+ expect(robots).toContain("Sitemap: https://example.com/sitemap.xml")
15
+ })
16
+
17
+ it("includes disallow rules", () => {
18
+ const options: RobotsOptions = {
19
+ sitemapUrl: "https://example.com/sitemap.xml",
20
+ disallow: ["/admin/", "/dashboard/"]
21
+ }
22
+
23
+ const robots = buildRobotsTxt(options)
24
+
25
+ expect(robots).toContain("Disallow: /admin/")
26
+ expect(robots).toContain("Disallow: /dashboard/")
27
+ })
28
+
29
+ it("includes allow rules", () => {
30
+ const options: RobotsOptions = {
31
+ sitemapUrl: "https://example.com/sitemap.xml",
32
+ allow: ["/api/public/"]
33
+ }
34
+
35
+ const robots = buildRobotsTxt(options)
36
+
37
+ expect(robots).toContain("Allow: /api/public/")
38
+ })
39
+
40
+ it("includes user-agent specific rules", () => {
41
+ const options: RobotsOptions = {
42
+ sitemapUrl: "https://example.com/sitemap.xml",
43
+ userAgentRules: [
44
+ {
45
+ userAgent: "GPTBot",
46
+ disallow: ["/admin/"]
47
+ }
48
+ ]
49
+ }
50
+
51
+ const robots = buildRobotsTxt(options)
52
+
53
+ expect(robots).toContain("User-agent: GPTBot")
54
+ expect(robots).toContain("Disallow: /admin/")
55
+ })
56
+
57
+ it("orders user-agent specific rules before wildcard", () => {
58
+ const options: RobotsOptions = {
59
+ sitemapUrl: "https://example.com/sitemap.xml",
60
+ userAgentRules: [
61
+ {
62
+ userAgent: "GPTBot",
63
+ disallow: ["/admin/"]
64
+ }
65
+ ]
66
+ }
67
+
68
+ const robots = buildRobotsTxt(options)
69
+
70
+ const gptBotIndex = robots.indexOf("User-agent: GPTBot")
71
+ const wildcardIndex = robots.indexOf("User-agent: *")
72
+
73
+ expect(gptBotIndex).toBeLessThan(wildcardIndex)
74
+ })
75
+ })
package/src/robots.ts ADDED
@@ -0,0 +1,59 @@
1
+ import type { RobotsOptions, UserAgentRule } from "./types.js"
2
+
3
+ /**
4
+ * Build robots.txt content from options
5
+ */
6
+ export function buildRobotsTxt(options: RobotsOptions): string {
7
+ const lines: string[] = []
8
+
9
+ // Add user-agent specific rules first
10
+ if (options.userAgentRules && options.userAgentRules.length > 0) {
11
+ for (const rule of options.userAgentRules) {
12
+ lines.push(buildUserAgentSection(rule))
13
+ lines.push("")
14
+ }
15
+ }
16
+
17
+ // Add wildcard user-agent (all bots)
18
+ lines.push("User-agent: *")
19
+
20
+ if (options.disallow && options.disallow.length > 0) {
21
+ for (const path of options.disallow) {
22
+ lines.push(`Disallow: ${path}`)
23
+ }
24
+ }
25
+
26
+ if (options.allow && options.allow.length > 0) {
27
+ for (const path of options.allow) {
28
+ lines.push(`Allow: ${path}`)
29
+ }
30
+ }
31
+
32
+ lines.push("")
33
+ lines.push(`Sitemap: ${options.sitemapUrl}`)
34
+
35
+ return lines.join("\n")
36
+ }
37
+
38
+ /**
39
+ * Build a user-agent specific section
40
+ */
41
+ function buildUserAgentSection(rule: UserAgentRule): string {
42
+ const lines: string[] = []
43
+
44
+ lines.push(`User-agent: ${rule.userAgent}`)
45
+
46
+ if (rule.disallow && rule.disallow.length > 0) {
47
+ for (const path of rule.disallow) {
48
+ lines.push(`Disallow: ${path}`)
49
+ }
50
+ }
51
+
52
+ if (rule.allow && rule.allow.length > 0) {
53
+ for (const path of rule.allow) {
54
+ lines.push(`Allow: ${path}`)
55
+ }
56
+ }
57
+
58
+ return lines.join("\n")
59
+ }
@@ -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
+ }
package/src/types.ts ADDED
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Change frequency for sitemap entries
3
+ */
4
+ export type ChangeFreq = "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never"
5
+
6
+ /**
7
+ * SEO entry — a single page to include in sitemaps
8
+ */
9
+ export interface SeoEntry {
10
+ /**
11
+ * Locale-less path, e.g. "/company/blog/hello". Leading slash required.
12
+ */
13
+ path: string
14
+ lastmod?: Date
15
+ changefreq?: ChangeFreq
16
+ priority?: number
17
+ /**
18
+ * Restrict to a subset of locales; defaults to all configured locales.
19
+ */
20
+ locales?: readonly string[]
21
+ }
22
+
23
+ /**
24
+ * Locale configuration for SEO builders
25
+ */
26
+ export interface LocaleConfig {
27
+ /**
28
+ * Absolute origin, e.g. "https://rimelight.com"
29
+ */
30
+ site: string
31
+ /**
32
+ * Map of locale segment -> hreflang, e.g. { en: "en-US", pt: "pt-BR" }
33
+ */
34
+ locales: Record<string, string>
35
+ defaultLocale: string
36
+ /**
37
+ * Emit the default locale without a prefix. Default: false.
38
+ */
39
+ prefixDefaultLocale?: boolean
40
+ trailingSlash?: "always" | "never"
41
+ }
42
+
43
+ /**
44
+ * Fully resolved sitemap URL with alternates
45
+ */
46
+ export interface SitemapUrl {
47
+ loc: string
48
+ lastmod?: string
49
+ changefreq?: ChangeFreq
50
+ priority?: number
51
+ alternates?: SitemapAlternate[]
52
+ }
53
+
54
+ /**
55
+ * Language alternate for a sitemap URL
56
+ */
57
+ export interface SitemapAlternate {
58
+ hreflang: string
59
+ href: string
60
+ }
61
+
62
+ /**
63
+ * Options for robots.txt generation
64
+ */
65
+ export interface RobotsOptions {
66
+ /**
67
+ * The sitemap URL to reference
68
+ */
69
+ sitemapUrl: string
70
+ /**
71
+ * Paths to disallow for all user agents
72
+ */
73
+ disallow?: string[]
74
+ /**
75
+ * Paths to allow for all user agents
76
+ */
77
+ allow?: string[]
78
+ /**
79
+ * User-agent specific rules
80
+ */
81
+ userAgentRules?: UserAgentRule[]
82
+ }
83
+
84
+ /**
85
+ * User-agent specific robots.txt rule
86
+ */
87
+ export interface UserAgentRule {
88
+ userAgent: string
89
+ disallow?: string[]
90
+ allow?: string[]
91
+ }
92
+
93
+ /**
94
+ * Site identity and SEO defaults shared across all Rimelight Astro sites. The UI-specific overrides
95
+ * (component themes, shortcuts, etc.) live in UIConfig (packages/ui) and are passed directly to the
96
+ * ui() integration.
97
+ */
98
+ export interface SiteConfig {
99
+ id: string
100
+ name: string
101
+ description: string
102
+ url: string
103
+ ogImage: string
104
+ author: string
105
+ email: string
106
+ branding: {
107
+ logo: {
108
+ alt: string
109
+ }
110
+ favicon: {
111
+ svg: string
112
+ }
113
+ colors: {
114
+ themeColor: string
115
+ backgroundColor: string
116
+ }
117
+ }
118
+ seo: {
119
+ titleTemplate: string
120
+ ogImageFallback: string
121
+ maxDescriptionLength: number
122
+ authorHandle?: string
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Open Graph image with alt text
128
+ */
129
+ export interface OGImage {
130
+ /**
131
+ * Image URL (relative or absolute)
132
+ */
133
+ src: string
134
+ /**
135
+ * Alt text for the image
136
+ */
137
+ alt: string
138
+ }
139
+
140
+ /**
141
+ * RSS feed link configuration
142
+ */
143
+ export interface RSSFeed {
144
+ /**
145
+ * RSS feed URL
146
+ */
147
+ href: string
148
+ /**
149
+ * Feed title (defaults to "RSS")
150
+ */
151
+ title?: string
152
+ }
153
+
154
+ /**
155
+ * Props for the RLAHead Astro component. Defined here so consumers can construct head props without
156
+ * importing @rimelight/ui.
157
+ */
158
+ export interface HeadProps {
159
+ /**
160
+ * Page title
161
+ */
162
+ title?: string
163
+ /**
164
+ * Page description
165
+ */
166
+ description?: string
167
+ /**
168
+ * Open Graph image with alt text
169
+ */
170
+ ogImage?: OGImage
171
+ /**
172
+ * Open Graph type (e.g., "website", "article")
173
+ */
174
+ ogType?: string
175
+ /**
176
+ * Open Graph locale (defaults to "en_US")
177
+ */
178
+ ogLocale?: string
179
+ /**
180
+ * Whether to prevent indexing
181
+ */
182
+ noindex?: boolean
183
+ /**
184
+ * Whether this is a 404 page
185
+ */
186
+ is404?: boolean
187
+ /**
188
+ * Robots metadata (overrides noindex/is404 logic if provided)
189
+ */
190
+ robots?: string
191
+ /**
192
+ * Canonical URL
193
+ */
194
+ canonical?: string
195
+ /**
196
+ * Language alternates for i18n
197
+ */
198
+ languageAlternates?: Array<{ hreflang: string; href: string }>
199
+ /**
200
+ * Site configuration for defaults
201
+ */
202
+ config?: SiteConfig
203
+ /**
204
+ * Whether to enable View Transitions (ClientRouter)
205
+ */
206
+ transitions?: boolean
207
+ /**
208
+ * Meta charset
209
+ */
210
+ charset?: string
211
+ /**
212
+ * Meta viewport
213
+ */
214
+ viewport?: string
215
+ /**
216
+ * RSS feed configuration
217
+ */
218
+ rssFeed?: RSSFeed
219
+ }