@rimelight/seo 0.0.3 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +6 -38
- package/dist/index.mjs +5 -176
- package/dist/integration.d.mts +23 -0
- package/dist/integration.mjs +33 -0
- package/dist/llms-BVLxzjr-.d.mts +41 -0
- package/dist/llms.d.mts +2 -0
- package/dist/llms.mjs +75 -0
- package/dist/meta.d.mts +42 -0
- package/dist/meta.mjs +66 -0
- package/dist/robots.d.mts +3 -4
- package/dist/routes/llms.txt.d.mts +4 -0
- package/dist/routes/llms.txt.mjs +20 -0
- package/dist/routes/robots.txt.d.mts +4 -0
- package/dist/routes/robots.txt.mjs +18 -0
- package/dist/routes/sitemap.xml.d.mts +4 -0
- package/dist/routes/sitemap.xml.mjs +13 -0
- package/dist/schema-C8v69blQ.d.mts +28 -0
- package/dist/schema.d.mts +2 -0
- package/dist/schema.mjs +52 -0
- package/dist/sitemap.d.mts +6 -7
- package/dist/types.d.mts +277 -2
- package/package.json +10 -5
- package/src/components/SEOHead.astro +74 -21
- package/src/env.d.ts +8 -0
- package/src/index.ts +42 -0
- package/src/integration.ts +74 -0
- package/src/llms.test.ts +61 -0
- package/src/llms.ts +124 -0
- package/src/meta.test.ts +134 -0
- package/src/meta.ts +100 -0
- package/src/robots.test.ts +75 -0
- package/src/robots.ts +59 -0
- package/src/routes/llms.txt.ts +22 -0
- package/src/routes/robots.txt.ts +20 -0
- package/src/routes/sitemap.xml.ts +16 -0
- package/src/schema.test.ts +45 -0
- package/src/schema.ts +96 -0
- package/src/sitemap.test.ts +191 -0
- package/src/sitemap.ts +166 -0
- package/src/types.ts +281 -0
- package/dist/types-Ck_Zs5Ws.d.mts +0 -301
package/src/llms.test.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test"
|
|
2
|
+
import { buildLlmsTxt, buildLlmsFullTxt } from "./llms.js"
|
|
3
|
+
|
|
4
|
+
describe("seo-llms", () => {
|
|
5
|
+
it("builds standard /llms.txt with sections and pages", () => {
|
|
6
|
+
const output = buildLlmsTxt({
|
|
7
|
+
site: "https://rimelight.com",
|
|
8
|
+
title: "Rimelight Documentation",
|
|
9
|
+
description: "Docs for developers and AI agents.",
|
|
10
|
+
pages: [
|
|
11
|
+
{
|
|
12
|
+
title: "Introduction",
|
|
13
|
+
url: "https://rimelight.com/en/docs/overview/introduction",
|
|
14
|
+
markdownUrl: "https://rimelight.com/en/docs/overview/introduction.md",
|
|
15
|
+
description: "Getting started guide",
|
|
16
|
+
section: "Overview"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
title: "Architecture",
|
|
20
|
+
url: "https://rimelight.com/en/docs/overview/architecture",
|
|
21
|
+
markdownUrl: "https://rimelight.com/en/docs/overview/architecture.md",
|
|
22
|
+
section: "Overview"
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
expect(output).toContain("# Rimelight Documentation")
|
|
28
|
+
expect(output).toContain(
|
|
29
|
+
"Full corpus (all pages, one document): https://rimelight.com/llms-full.txt"
|
|
30
|
+
)
|
|
31
|
+
expect(output).toContain("### Overview")
|
|
32
|
+
expect(output).toContain(
|
|
33
|
+
"- [Introduction](https://rimelight.com/en/docs/overview/introduction.md) — Getting started guide"
|
|
34
|
+
)
|
|
35
|
+
expect(output).toContain(
|
|
36
|
+
"- [Architecture](https://rimelight.com/en/docs/overview/architecture.md)"
|
|
37
|
+
)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("builds full corpus /llms-full.txt", () => {
|
|
41
|
+
const output = buildLlmsFullTxt({
|
|
42
|
+
site: "https://rimelight.com",
|
|
43
|
+
title: "Rimelight Docs",
|
|
44
|
+
pages: [
|
|
45
|
+
{
|
|
46
|
+
title: "Quickstart",
|
|
47
|
+
url: "https://rimelight.com/en/docs/quickstart",
|
|
48
|
+
description: "Step-by-step tutorial",
|
|
49
|
+
version: "v1",
|
|
50
|
+
markdown: "Run `vp dev` to start the development server."
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
expect(output).toContain("# Rimelight Docs — Full Documentation Corpus")
|
|
56
|
+
expect(output).toContain("## Quickstart")
|
|
57
|
+
expect(output).toContain("URL: https://rimelight.com/en/docs/quickstart")
|
|
58
|
+
expect(output).toContain("Version: v1")
|
|
59
|
+
expect(output).toContain("Run `vp dev` to start the development server.")
|
|
60
|
+
})
|
|
61
|
+
})
|
package/src/llms.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
export interface LlmsPage {
|
|
2
|
+
title: string
|
|
3
|
+
url: string
|
|
4
|
+
description?: string
|
|
5
|
+
markdownUrl?: string
|
|
6
|
+
section?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface LlmsTxtOptions {
|
|
10
|
+
site: string
|
|
11
|
+
title: string
|
|
12
|
+
description?: string
|
|
13
|
+
fullCorpusUrl?: string
|
|
14
|
+
pages: LlmsPage[]
|
|
15
|
+
sections?: Array<{ label: string; url: string }>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface LlmsFullTxtOptions {
|
|
19
|
+
site: string
|
|
20
|
+
title: string
|
|
21
|
+
description?: string
|
|
22
|
+
pages: Array<{
|
|
23
|
+
title: string
|
|
24
|
+
url: string
|
|
25
|
+
description?: string
|
|
26
|
+
markdown: string
|
|
27
|
+
version?: string
|
|
28
|
+
}>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Builds standard /llms.txt document format for AI discovery.
|
|
33
|
+
*/
|
|
34
|
+
export function buildLlmsTxt(options: LlmsTxtOptions): string {
|
|
35
|
+
const {
|
|
36
|
+
site,
|
|
37
|
+
title,
|
|
38
|
+
description,
|
|
39
|
+
fullCorpusUrl = `${site.replace(/\/$/, "")}/llms-full.txt`,
|
|
40
|
+
pages,
|
|
41
|
+
sections = []
|
|
42
|
+
} = options
|
|
43
|
+
|
|
44
|
+
const lines = [
|
|
45
|
+
`# ${title}`,
|
|
46
|
+
"",
|
|
47
|
+
description || "Documentation index for AI agents.",
|
|
48
|
+
"",
|
|
49
|
+
`Full corpus (all pages, one document): ${fullCorpusUrl}`,
|
|
50
|
+
""
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
if (sections.length > 0) {
|
|
54
|
+
lines.push("## Sections", "")
|
|
55
|
+
for (const sec of sections) {
|
|
56
|
+
lines.push(`- [${sec.label}](${sec.url})`)
|
|
57
|
+
}
|
|
58
|
+
lines.push("")
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
lines.push("## Pages", "")
|
|
62
|
+
|
|
63
|
+
// Group pages by section if present, or flat
|
|
64
|
+
const grouped = new Map<string, LlmsPage[]>()
|
|
65
|
+
for (const page of pages) {
|
|
66
|
+
const sec = page.section || "General"
|
|
67
|
+
if (!grouped.has(sec)) grouped.set(sec, [])
|
|
68
|
+
grouped.get(sec)!.push(page)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (grouped.size === 1 && grouped.has("General")) {
|
|
72
|
+
for (const page of pages) {
|
|
73
|
+
const pageUrl = page.markdownUrl || page.url
|
|
74
|
+
const desc = page.description ? ` — ${page.description}` : ""
|
|
75
|
+
lines.push(`- [${page.title}](${pageUrl})${desc}`)
|
|
76
|
+
}
|
|
77
|
+
} else {
|
|
78
|
+
for (const [secName, secPages] of grouped.entries()) {
|
|
79
|
+
lines.push(`### ${secName}`, "")
|
|
80
|
+
for (const page of secPages) {
|
|
81
|
+
const pageUrl = page.markdownUrl || page.url
|
|
82
|
+
const desc = page.description ? ` — ${page.description}` : ""
|
|
83
|
+
lines.push(`- [${page.title}](${pageUrl})${desc}`)
|
|
84
|
+
}
|
|
85
|
+
lines.push("")
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
lines.push("")
|
|
90
|
+
return lines.join("\n")
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Builds concatenated /llms-full.txt single corpus document for AI agents.
|
|
95
|
+
*/
|
|
96
|
+
export function buildLlmsFullTxt(options: LlmsFullTxtOptions): string {
|
|
97
|
+
const { title, description, pages } = options
|
|
98
|
+
const parts = [
|
|
99
|
+
`# ${title} — Full Documentation Corpus`,
|
|
100
|
+
"",
|
|
101
|
+
description || "Complete documentation corpus for AI agents.",
|
|
102
|
+
"",
|
|
103
|
+
"---",
|
|
104
|
+
""
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
for (const page of pages) {
|
|
108
|
+
const metaHeader = [
|
|
109
|
+
`## ${page.title}`,
|
|
110
|
+
"",
|
|
111
|
+
`URL: ${page.url}`,
|
|
112
|
+
...(page.description ? [`Description: ${page.description}`] : []),
|
|
113
|
+
...(page.version ? [`Version: ${page.version}`] : []),
|
|
114
|
+
"",
|
|
115
|
+
page.markdown.trim(),
|
|
116
|
+
"",
|
|
117
|
+
"---",
|
|
118
|
+
""
|
|
119
|
+
]
|
|
120
|
+
parts.push(...metaHeader)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return parts.join("\n")
|
|
124
|
+
}
|
package/src/meta.test.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, it, expect } from "vite-plus/test"
|
|
2
|
+
import {
|
|
3
|
+
buildCanonicalUrl,
|
|
4
|
+
buildPageTitle,
|
|
5
|
+
buildSafeDescription,
|
|
6
|
+
buildRobotsMetaContent,
|
|
7
|
+
buildWebSiteSchema,
|
|
8
|
+
deepMerge
|
|
9
|
+
} from "./meta.js"
|
|
10
|
+
|
|
11
|
+
const BASE = "https://example.com"
|
|
12
|
+
|
|
13
|
+
describe("buildCanonicalUrl", () => {
|
|
14
|
+
it("adds trailing slash to clean paths", () => {
|
|
15
|
+
expect(buildCanonicalUrl("/about", BASE)).toBe("https://example.com/about/")
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("strips trailing slash from query-param paths", () => {
|
|
19
|
+
expect(buildCanonicalUrl("/search?q=hello", BASE)).toBe("https://example.com/search?q=hello")
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("handles root path", () => {
|
|
23
|
+
expect(buildCanonicalUrl("/", BASE)).toBe("https://example.com/")
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it("resolves relative paths against base", () => {
|
|
27
|
+
expect(buildCanonicalUrl(new URL("https://example.com/docs"), BASE)).toBe(
|
|
28
|
+
"https://example.com/docs/"
|
|
29
|
+
)
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe("buildPageTitle", () => {
|
|
34
|
+
const opts = { siteName: "My Site", titleTemplate: "%s | My Site" }
|
|
35
|
+
|
|
36
|
+
it("applies title template", () => {
|
|
37
|
+
expect(buildPageTitle({ ...opts, title: "About" })).toBe("About | My Site")
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("returns siteName when no title", () => {
|
|
41
|
+
expect(buildPageTitle(opts)).toBe("My Site")
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it("returns 404 label for error pages", () => {
|
|
45
|
+
expect(buildPageTitle({ ...opts, is404: true })).toBe("404 - Not Found | My Site")
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("404 overrides title if both provided", () => {
|
|
49
|
+
expect(buildPageTitle({ ...opts, title: "Oops", is404: true })).toBe(
|
|
50
|
+
"404 - Not Found | My Site"
|
|
51
|
+
)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("bypasses title template when absolute is true", () => {
|
|
55
|
+
expect(buildPageTitle({ ...opts, title: "Landing Page", absolute: true })).toBe("Landing Page")
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe("deepMerge", () => {
|
|
60
|
+
it("deep merges nested objects", () => {
|
|
61
|
+
const base = {
|
|
62
|
+
openGraph: { type: "website", siteName: "Base", locale: "en_US" },
|
|
63
|
+
twitter: { card: "summary", site: "@base" }
|
|
64
|
+
}
|
|
65
|
+
const override = {
|
|
66
|
+
openGraph: { siteName: "Override" },
|
|
67
|
+
twitter: { card: "summary_large_image" }
|
|
68
|
+
}
|
|
69
|
+
const result = deepMerge(base, override)
|
|
70
|
+
expect(result.openGraph.type).toBe("website")
|
|
71
|
+
expect(result.openGraph.siteName).toBe("Override")
|
|
72
|
+
expect(result.openGraph.locale).toBe("en_US")
|
|
73
|
+
expect(result.twitter.card).toBe("summary_large_image")
|
|
74
|
+
expect(result.twitter.site).toBe("@base")
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe("buildSafeDescription", () => {
|
|
79
|
+
it("returns description unchanged when within limit", () => {
|
|
80
|
+
expect(buildSafeDescription("Short desc", 160)).toBe("Short desc")
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("truncates and appends ellipsis when over limit", () => {
|
|
84
|
+
const long = "a".repeat(200)
|
|
85
|
+
const result = buildSafeDescription(long, 160)
|
|
86
|
+
expect(result).toHaveLength(160)
|
|
87
|
+
expect(result.endsWith("...")).toBe(true)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it("handles empty string", () => {
|
|
91
|
+
expect(buildSafeDescription("", 160)).toBe("")
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it("truncates at exact boundary", () => {
|
|
95
|
+
const text = "a".repeat(160)
|
|
96
|
+
expect(buildSafeDescription(text, 160)).toBe(text)
|
|
97
|
+
expect(buildSafeDescription(text + "b", 160)).toHaveLength(160)
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe("buildRobotsMetaContent", () => {
|
|
102
|
+
it("returns indexable string by default", () => {
|
|
103
|
+
expect(buildRobotsMetaContent({})).toBe("index, follow, max-image-preview:large")
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it("returns noindex for noindex=true", () => {
|
|
107
|
+
expect(buildRobotsMetaContent({ noindex: true })).toBe("noindex, nofollow")
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it("returns noindex for is404=true", () => {
|
|
111
|
+
expect(buildRobotsMetaContent({ is404: true })).toBe("noindex, nofollow")
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it("respects override", () => {
|
|
115
|
+
expect(buildRobotsMetaContent({ override: "noarchive" })).toBe("noarchive")
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe("buildWebSiteSchema", () => {
|
|
120
|
+
const config = { name: "My Site", url: "https://example.com", description: "A test site" }
|
|
121
|
+
|
|
122
|
+
it("builds schema.org WebSite object", () => {
|
|
123
|
+
const schema = buildWebSiteSchema(config)
|
|
124
|
+
expect(schema["@context"]).toBe("https://schema.org")
|
|
125
|
+
expect(schema["@type"]).toBe("WebSite")
|
|
126
|
+
expect(schema["name"]).toBe("My Site")
|
|
127
|
+
expect(schema["url"]).toBe("https://example.com")
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it("prefers site param over config.url", () => {
|
|
131
|
+
const schema = buildWebSiteSchema(config, "https://override.com")
|
|
132
|
+
expect(schema["url"]).toBe("https://override.com")
|
|
133
|
+
})
|
|
134
|
+
})
|
package/src/meta.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
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
|
+
* Deep merge two metadata or configuration objects.
|
|
17
|
+
*/
|
|
18
|
+
export function deepMerge<T extends Record<string, any>>(
|
|
19
|
+
base: T,
|
|
20
|
+
override?: Record<string, any>
|
|
21
|
+
): T {
|
|
22
|
+
if (!override) return { ...base }
|
|
23
|
+
const result: Record<string, any> = { ...base }
|
|
24
|
+
for (const key of Object.keys(override)) {
|
|
25
|
+
const baseVal = base[key]
|
|
26
|
+
const overrideVal = override[key]
|
|
27
|
+
if (
|
|
28
|
+
baseVal &&
|
|
29
|
+
overrideVal &&
|
|
30
|
+
typeof baseVal === "object" &&
|
|
31
|
+
typeof overrideVal === "object" &&
|
|
32
|
+
!Array.isArray(baseVal) &&
|
|
33
|
+
!Array.isArray(overrideVal)
|
|
34
|
+
) {
|
|
35
|
+
result[key] = deepMerge(baseVal, overrideVal)
|
|
36
|
+
} else if (overrideVal !== undefined) {
|
|
37
|
+
result[key] = overrideVal
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return result as T
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build a page title using a site's title template. Falls back to siteName for untitled pages; uses
|
|
45
|
+
* a 404 label for error pages. Supports absolute title bypass.
|
|
46
|
+
*/
|
|
47
|
+
export function buildPageTitle(opts: {
|
|
48
|
+
title?: string
|
|
49
|
+
siteName: string
|
|
50
|
+
titleTemplate: string
|
|
51
|
+
is404?: boolean
|
|
52
|
+
absolute?: boolean
|
|
53
|
+
}): string {
|
|
54
|
+
const { title, siteName, titleTemplate, is404, absolute } = opts
|
|
55
|
+
if (is404) return `404 - Not Found | ${siteName}`
|
|
56
|
+
if (!title) return siteName
|
|
57
|
+
if (absolute) return title
|
|
58
|
+
return titleTemplate.replace("%s", title)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Truncate a description to a maximum character length, appending "…" if trimmed.
|
|
63
|
+
*/
|
|
64
|
+
export function buildSafeDescription(description: string, maxLength: number): string {
|
|
65
|
+
if (!description) return description
|
|
66
|
+
if (description.length <= maxLength) return description
|
|
67
|
+
return description.slice(0, maxLength - 3) + "..."
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build the value for the <meta name="robots"> tag. Returns the full directive string including
|
|
72
|
+
* max-image-preview for indexable pages.
|
|
73
|
+
*/
|
|
74
|
+
export function buildRobotsMetaContent(opts: {
|
|
75
|
+
noindex?: boolean
|
|
76
|
+
is404?: boolean
|
|
77
|
+
override?: string
|
|
78
|
+
}): string {
|
|
79
|
+
if (opts.override) return opts.override
|
|
80
|
+
return !opts.noindex && !opts.is404
|
|
81
|
+
? "index, follow, max-image-preview:large"
|
|
82
|
+
: "noindex, nofollow"
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* \n * Build a Schema.org WebSite JSON-LD object.\n * Pass the result to JSON.stringify and emit
|
|
87
|
+
* via <script type="application/ld+json">.\n
|
|
88
|
+
*/
|
|
89
|
+
export function buildWebSiteSchema(
|
|
90
|
+
config: Pick<SiteConfig, "name" | "url" | "description">,
|
|
91
|
+
site?: string
|
|
92
|
+
): Record<string, unknown> {
|
|
93
|
+
return {
|
|
94
|
+
"@context": "https://schema.org",
|
|
95
|
+
"@type": "WebSite",
|
|
96
|
+
"name": config.name,
|
|
97
|
+
"url": site ?? config.url,
|
|
98
|
+
"description": config.description
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -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,22 @@
|
|
|
1
|
+
import type { APIRoute } from "astro"
|
|
2
|
+
import { buildLlmsTxt } from "../llms.js"
|
|
3
|
+
import seoConfig from "virtual:rimelight-seo-config"
|
|
4
|
+
|
|
5
|
+
export const GET: APIRoute = ({ site }) => {
|
|
6
|
+
const llmsOpts = typeof seoConfig?.llms === "object" ? seoConfig.llms : {}
|
|
7
|
+
const siteStr = site ? site.toString().replace(/\/$/, "") : ""
|
|
8
|
+
const hostname = site ? new URL(site).hostname : "Site"
|
|
9
|
+
const content = buildLlmsTxt({
|
|
10
|
+
site: siteStr,
|
|
11
|
+
title: hostname,
|
|
12
|
+
pages: [],
|
|
13
|
+
...llmsOpts
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
return new Response(content, {
|
|
17
|
+
headers: {
|
|
18
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
19
|
+
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400"
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { APIRoute } from "astro"
|
|
2
|
+
import { buildRobotsTxt } from "../robots.js"
|
|
3
|
+
import seoConfig from "virtual:rimelight-seo-config"
|
|
4
|
+
|
|
5
|
+
export const GET: APIRoute = ({ site }) => {
|
|
6
|
+
const sitemapURL = site ? new URL("sitemap.xml", site).toString() : ""
|
|
7
|
+
const robotsOpts = typeof seoConfig?.robots === "object" ? seoConfig.robots : {}
|
|
8
|
+
const robots = buildRobotsTxt({
|
|
9
|
+
sitemapUrl: sitemapURL,
|
|
10
|
+
allow: ["/"],
|
|
11
|
+
...robotsOpts
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
return new Response(robots, {
|
|
15
|
+
headers: {
|
|
16
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
17
|
+
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400"
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { APIRoute } from "astro"
|
|
2
|
+
import { buildSitemapXml } from "../sitemap.js"
|
|
3
|
+
import seoConfig from "virtual:rimelight-seo-config"
|
|
4
|
+
|
|
5
|
+
export const GET: APIRoute = () => {
|
|
6
|
+
const sitemapOpts = typeof seoConfig?.sitemap === "object" ? seoConfig.sitemap : {}
|
|
7
|
+
const urls = sitemapOpts.urls || []
|
|
8
|
+
const xml = buildSitemapXml(urls)
|
|
9
|
+
|
|
10
|
+
return new Response(xml, {
|
|
11
|
+
headers: {
|
|
12
|
+
"Content-Type": "application/xml; charset=utf-8",
|
|
13
|
+
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400"
|
|
14
|
+
}
|
|
15
|
+
})
|
|
16
|
+
}
|
|
@@ -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
|
+
})
|