@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 +19 -0
- package/package.json +48 -0
- package/src/components/SEOHead.astro +124 -0
- package/src/env.d.ts +3 -0
- package/src/index.ts +30 -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/sitemap.test.ts +191 -0
- package/src/sitemap.ts +166 -0
- package/src/types.ts +219 -0
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
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"
|
package/src/meta.test.ts
ADDED
|
@@ -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&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
|
+
}
|
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
|
+
}
|