@rimelight/seo 0.0.3 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +7 -7
- package/dist/robots.d.mts +1 -1
- package/dist/sitemap.d.mts +1 -1
- package/dist/{types-Ck_Zs5Ws.d.mts → types-e7gqYZwc.d.mts} +4 -0
- package/dist/types.d.mts +1 -1
- package/package.json +3 -3
- package/src/components/SEOHead.astro +9 -3
- package/src/env.d.ts +3 -0
- package/src/index.ts +38 -0
- package/src/llms.test.ts +61 -0
- package/src/llms.ts +124 -0
- package/src/meta.test.ts +110 -0
- package/src/meta.ts +70 -0
- package/src/robots.test.ts +75 -0
- package/src/robots.ts +59 -0
- package/src/schema.test.ts +45 -0
- package/src/schema.ts +96 -0
- package/src/sitemap.test.ts +191 -0
- package/src/sitemap.ts +166 -0
- package/src/types.ts +242 -0
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { _ as ArticleSchemaOptions, a as RSSFeed, b as buildBreadcrumbSchema, c as SiteConfig, d as UserAgentRule, f as LlmsFullTxtOptions, g as buildLlmsTxt, h as buildLlmsFullTxt, i as OGImage, l as SitemapAlternate, m as LlmsTxtOptions, n as HeadProps, o as RobotsOptions, p as LlmsPage, r as LocaleConfig, s as SeoEntry, t as ChangeFreq, u as SitemapUrl, v as BreadcrumbItem, y as buildArticleSchema } from "./types-
|
|
1
|
+
import { _ as ArticleSchemaOptions, a as RSSFeed, b as buildBreadcrumbSchema, c as SiteConfig, d as UserAgentRule, f as LlmsFullTxtOptions, g as buildLlmsTxt, h as buildLlmsFullTxt, i as OGImage, l as SitemapAlternate, m as LlmsTxtOptions, n as HeadProps, o as RobotsOptions, p as LlmsPage, r as LocaleConfig, s as SeoEntry, t as ChangeFreq, u as SitemapUrl, v as BreadcrumbItem, y as buildArticleSchema } from "./types-e7gqYZwc.mjs";
|
|
2
2
|
import { buildSitemapIndexXml, buildSitemapUrls, buildSitemapXml, chunkSitemapUrls } from "./sitemap.mjs";
|
|
3
3
|
import { buildRobotsTxt } from "./robots.mjs";
|
|
4
4
|
//#region src/meta.d.ts
|
package/dist/index.mjs
CHANGED
|
@@ -139,17 +139,17 @@ function buildArticleSchema(options) {
|
|
|
139
139
|
"@id": url
|
|
140
140
|
}
|
|
141
141
|
};
|
|
142
|
-
if (description) schema
|
|
143
|
-
if (image) schema
|
|
144
|
-
if (datePublished) schema
|
|
145
|
-
if (dateModified) schema
|
|
146
|
-
else if (datePublished) schema
|
|
147
|
-
if (authorName) schema
|
|
142
|
+
if (description) schema["description"] = description;
|
|
143
|
+
if (image) schema["image"] = image;
|
|
144
|
+
if (datePublished) schema["datePublished"] = datePublished instanceof Date ? datePublished.toISOString() : datePublished;
|
|
145
|
+
if (dateModified) schema["dateModified"] = dateModified instanceof Date ? dateModified.toISOString() : dateModified;
|
|
146
|
+
else if (datePublished) schema["dateModified"] = schema["datePublished"];
|
|
147
|
+
if (authorName) schema["author"] = {
|
|
148
148
|
"@type": "Person",
|
|
149
149
|
"name": authorName,
|
|
150
150
|
...authorUrl ? { url: authorUrl } : {}
|
|
151
151
|
};
|
|
152
|
-
if (publisherName) schema
|
|
152
|
+
if (publisherName) schema["publisher"] = {
|
|
153
153
|
"@type": "Organization",
|
|
154
154
|
"name": publisherName,
|
|
155
155
|
...publisherLogo ? { logo: {
|
package/dist/robots.d.mts
CHANGED
package/dist/sitemap.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { r as LocaleConfig, s as SeoEntry, u as SitemapUrl } from "./types-
|
|
1
|
+
import { r as LocaleConfig, s as SeoEntry, u as SitemapUrl } from "./types-e7gqYZwc.mjs";
|
|
2
2
|
//#region src/sitemap.d.ts
|
|
3
3
|
/**
|
|
4
4
|
* Build fully-resolved sitemap URLs from entries and locale config
|
package/dist/types.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { _ as ArticleSchemaOptions, a as RSSFeed, c as SiteConfig, d as UserAgentRule, f as LlmsFullTxtOptions, i as OGImage, l as SitemapAlternate, m as LlmsTxtOptions, n as HeadProps, o as RobotsOptions, p as LlmsPage, r as LocaleConfig, s as SeoEntry, t as ChangeFreq, u as SitemapUrl, v as BreadcrumbItem } from "./types-
|
|
1
|
+
import { _ as ArticleSchemaOptions, a as RSSFeed, c as SiteConfig, d as UserAgentRule, f as LlmsFullTxtOptions, i as OGImage, l as SitemapAlternate, m as LlmsTxtOptions, n as HeadProps, o as RobotsOptions, p as LlmsPage, r as LocaleConfig, s as SeoEntry, t as ChangeFreq, u as SitemapUrl, v as BreadcrumbItem } from "./types-e7gqYZwc.mjs";
|
|
2
2
|
export { type ArticleSchemaOptions, type BreadcrumbItem, ChangeFreq, HeadProps, type LlmsFullTxtOptions, type LlmsPage, type LlmsTxtOptions, LocaleConfig, OGImage, RSSFeed, RobotsOptions, SeoEntry, SiteConfig, SitemapAlternate, SitemapUrl, UserAgentRule };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rimelight/seo",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Rimelight Entertainment's SEO Package",
|
|
6
6
|
"homepage": "https://rimelight.com/docs",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
},
|
|
18
18
|
"files": [
|
|
19
19
|
"dist",
|
|
20
|
-
"src
|
|
20
|
+
"src"
|
|
21
21
|
],
|
|
22
22
|
"type": "module",
|
|
23
23
|
"exports": {
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"access": "public"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
|
-
"@rimelight/config": "0.0.
|
|
47
|
+
"@rimelight/config": "0.0.5",
|
|
48
48
|
"astro": "7.3.1",
|
|
49
49
|
"typescript": "6.0.3"
|
|
50
50
|
},
|
|
@@ -21,6 +21,7 @@ const {
|
|
|
21
21
|
canonical,
|
|
22
22
|
languageAlternates = [],
|
|
23
23
|
config,
|
|
24
|
+
favicon: customFavicon,
|
|
24
25
|
charset = 'utf-8',
|
|
25
26
|
viewport = 'width=device-width, initial-scale=1',
|
|
26
27
|
rssFeed,
|
|
@@ -35,7 +36,12 @@ const siteName = config?.name || 'Rimelight'
|
|
|
35
36
|
const siteDescription = config?.description || ''
|
|
36
37
|
const ogImageFallback = config?.seo?.ogImageFallback || config?.ogImage || ''
|
|
37
38
|
const themeColor = config?.branding?.colors?.themeColor
|
|
38
|
-
const favicon = config?.branding?.favicon?.svg || '/favicon.svg'
|
|
39
|
+
const favicon = customFavicon || config?.branding?.favicon?.svg || '/favicon.svg'
|
|
40
|
+
const faviconType = favicon.endsWith('.ico')
|
|
41
|
+
? 'image/x-icon'
|
|
42
|
+
: favicon.endsWith('.png')
|
|
43
|
+
? 'image/png'
|
|
44
|
+
: 'image/svg+xml'
|
|
39
45
|
const twitterHandle = config?.seo?.authorHandle
|
|
40
46
|
const titleTemplate = config?.seo?.titleTemplate || `%s | ${siteName}`
|
|
41
47
|
|
|
@@ -117,8 +123,8 @@ const robotsContent = buildRobotsMetaContent({
|
|
|
117
123
|
{twitterHandle && <meta property="twitter:creator" content={twitterHandle} />}
|
|
118
124
|
|
|
119
125
|
<!-- Icons & Manifest -->
|
|
120
|
-
<link rel="icon" type=
|
|
121
|
-
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
|
126
|
+
<link rel="icon" type={faviconType} href={favicon} />
|
|
127
|
+
{!customFavicon && <link rel="icon" href="/favicon.ico" sizes="32x32" />}
|
|
122
128
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
|
123
129
|
<link rel="manifest" href="/site.webmanifest" />
|
|
124
130
|
|
package/src/env.d.ts
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
LlmsPage,
|
|
14
|
+
LlmsTxtOptions,
|
|
15
|
+
LlmsFullTxtOptions,
|
|
16
|
+
ArticleSchemaOptions,
|
|
17
|
+
BreadcrumbItem
|
|
18
|
+
} from "./types.js"
|
|
19
|
+
|
|
20
|
+
export {
|
|
21
|
+
buildSitemapUrls,
|
|
22
|
+
buildSitemapXml,
|
|
23
|
+
buildSitemapIndexXml,
|
|
24
|
+
chunkSitemapUrls
|
|
25
|
+
} from "./sitemap.js"
|
|
26
|
+
|
|
27
|
+
export { buildRobotsTxt } from "./robots.js"
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
buildCanonicalUrl,
|
|
31
|
+
buildPageTitle,
|
|
32
|
+
buildSafeDescription,
|
|
33
|
+
buildRobotsMetaContent,
|
|
34
|
+
buildWebSiteSchema
|
|
35
|
+
} from "./meta.js"
|
|
36
|
+
|
|
37
|
+
export { buildLlmsTxt, buildLlmsFullTxt } from "./llms.js"
|
|
38
|
+
export { buildArticleSchema, buildBreadcrumbSchema } from "./schema.js"
|
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,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
|
+
}
|