@rimelight/seo 0.0.5 → 0.0.7
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/assets.d.mts +39 -0
- package/dist/assets.mjs +96 -0
- package/dist/catalog.d.mts +7 -0
- package/dist/catalog.mjs +10 -0
- package/dist/index.d.mts +9 -3
- package/dist/index.mjs +10 -2
- package/dist/integration.d.mts +3 -20
- package/dist/integration.mjs +257 -7
- package/dist/manifest.d.mts +7 -0
- package/dist/manifest.mjs +44 -0
- package/dist/og.d.mts +117 -0
- package/dist/og.mjs +302 -0
- package/dist/routes/api-catalog.d.mts +5 -0
- package/dist/routes/api-catalog.mjs +38 -0
- package/dist/routes/apple-touch-icon.png.d.mts +5 -0
- package/dist/routes/apple-touch-icon.png.mjs +17 -0
- package/dist/routes/favicon.ico.d.mts +5 -0
- package/dist/routes/favicon.ico.mjs +14 -0
- package/dist/routes/favicon.svg.d.mts +5 -0
- package/dist/routes/favicon.svg.mjs +12 -0
- package/dist/routes/icon-192.png.d.mts +5 -0
- package/dist/routes/icon-192.png.mjs +17 -0
- package/dist/routes/icon-512.png.d.mts +5 -0
- package/dist/routes/icon-512.png.mjs +17 -0
- package/dist/routes/icon-maskable-512.png.d.mts +5 -0
- package/dist/routes/icon-maskable-512.png.mjs +18 -0
- package/dist/routes/llms-full.txt.d.mts +5 -0
- package/dist/routes/llms-full.txt.mjs +57 -0
- package/dist/routes/llms.txt.d.mts +1 -0
- package/dist/routes/llms.txt.mjs +60 -12
- package/dist/routes/robots.txt.mjs +32 -4
- package/dist/routes/rss.xml.d.mts +4 -0
- package/dist/routes/rss.xml.mjs +79 -0
- package/dist/routes/security.txt.d.mts +5 -0
- package/dist/routes/security.txt.mjs +24 -0
- package/dist/routes/site.webmanifest.d.mts +5 -0
- package/dist/routes/site.webmanifest.mjs +23 -0
- package/dist/routes/sitemap.xml.mjs +18 -4
- package/dist/rss.d.mts +8 -0
- package/dist/rss.mjs +44 -0
- package/dist/security.d.mts +7 -0
- package/dist/security.mjs +34 -0
- package/dist/types.d.mts +263 -3
- package/package.json +38 -3
- package/src/assets.test.ts +70 -0
- package/src/assets.ts +137 -0
- package/src/catalog.test.ts +26 -0
- package/src/catalog.ts +11 -0
- package/src/components/SEOHead.astro +1 -1
- package/src/env.d.ts +51 -3
- package/src/index.ts +33 -2
- package/src/integration.ts +307 -28
- package/src/manifest.test.ts +41 -0
- package/src/manifest.ts +59 -0
- package/src/og.ts +436 -0
- package/src/routes/api-catalog.ts +55 -0
- package/src/routes/apple-touch-icon.png.ts +19 -0
- package/src/routes/favicon.ico.ts +19 -0
- package/src/routes/favicon.svg.ts +17 -0
- package/src/routes/icon-192.png.ts +19 -0
- package/src/routes/icon-512.png.ts +19 -0
- package/src/routes/icon-maskable-512.png.ts +24 -0
- package/src/routes/llms-full.txt.ts +77 -0
- package/src/routes/llms.txt.ts +84 -13
- package/src/routes/robots.txt.ts +47 -4
- package/src/routes/rss.xml.ts +129 -0
- package/src/routes/security.txt.ts +30 -0
- package/src/routes/site.webmanifest.ts +28 -0
- package/src/routes/sitemap.xml.ts +32 -5
- package/src/rss.test.ts +28 -0
- package/src/rss.ts +63 -0
- package/src/security.test.ts +46 -0
- package/src/security.ts +78 -0
- package/src/types.ts +286 -3
package/src/routes/llms.txt.ts
CHANGED
|
@@ -1,22 +1,93 @@
|
|
|
1
1
|
import type { APIRoute } from "astro"
|
|
2
|
-
import { buildLlmsTxt } from "../llms.js"
|
|
3
|
-
import
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
2
|
+
import { buildLlmsTxt, type LlmsPage } from "../llms.js"
|
|
3
|
+
import * as siteConfigMod from "virtual:rimelight-seo/site-config"
|
|
4
|
+
import * as dbMod from "virtual:rimelight-seo/db"
|
|
5
|
+
import * as corpusMod from "virtual:rimelight-seo/corpus"
|
|
6
|
+
import seoOptions from "virtual:rimelight-seo/options"
|
|
7
|
+
|
|
8
|
+
export const prerender = false
|
|
9
|
+
|
|
10
|
+
export const GET: APIRoute = async ({ site, url }) => {
|
|
11
|
+
const siteConfig = siteConfigMod.siteConfig || {}
|
|
12
|
+
const siteUrl = site ? site.toString() : siteConfig.url || `${url.protocol}//${url.host}`
|
|
13
|
+
const llmsOpts = typeof seoOptions?.llms === "object" ? seoOptions.llms : {}
|
|
14
|
+
|
|
15
|
+
let pages: LlmsPage[] = []
|
|
16
|
+
|
|
17
|
+
if (Array.isArray(llmsOpts.pages) && llmsOpts.pages.length > 0) {
|
|
18
|
+
pages = llmsOpts.pages
|
|
19
|
+
} else if (Array.isArray(corpusMod.corpusPages) && corpusMod.corpusPages.length > 0) {
|
|
20
|
+
pages = corpusMod.corpusPages.map((p: any) => {
|
|
21
|
+
const slug = p.slug === "index" ? "" : p.slug
|
|
22
|
+
const pageUrl = `${siteUrl.replace(/\/$/, "")}/${slug}`.replace(/\/+$/, "")
|
|
23
|
+
return {
|
|
24
|
+
title: p.title,
|
|
25
|
+
description: p.description,
|
|
26
|
+
url: pageUrl,
|
|
27
|
+
section: p.section ? p.section.toUpperCase() : "GENERAL"
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
} else if (dbMod.db) {
|
|
31
|
+
try {
|
|
32
|
+
const { pages: pagesTable } = await import("#db/schema").catch(() => ({ pages: null }))
|
|
33
|
+
const { and, isNotNull, isNull } = await import("drizzle-orm")
|
|
34
|
+
if (pagesTable) {
|
|
35
|
+
const docRows = await dbMod.db
|
|
36
|
+
.select()
|
|
37
|
+
.from(pagesTable)
|
|
38
|
+
.where(and(isNull(pagesTable.deletedAt), isNotNull(pagesTable.publishedVersionId)))
|
|
39
|
+
.catch(() => [])
|
|
40
|
+
|
|
41
|
+
const resolveLocalized = (val: unknown): string => {
|
|
42
|
+
if (typeof val === "string") return val
|
|
43
|
+
if (typeof val === "object" && val !== null) {
|
|
44
|
+
const rec = val as Record<string, string>
|
|
45
|
+
return rec["en"] || Object.values(rec)[0] || ""
|
|
46
|
+
}
|
|
47
|
+
return typeof val === "number" ? String(val) : ""
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
pages = docRows.map((p: any) => {
|
|
51
|
+
const title = resolveLocalized(p.title) || p.slug
|
|
52
|
+
const description = resolveLocalized(p.description)
|
|
53
|
+
const slug = p.slug === "index" ? "" : p.slug
|
|
54
|
+
const pathPrefix = p.type === "doc" ? "docs" : p.type || "docs"
|
|
55
|
+
const pageUrl = `${siteUrl.replace(/\/$/, "")}/en/${pathPrefix}/${slug}`.replace(
|
|
56
|
+
/\/+$/,
|
|
57
|
+
""
|
|
58
|
+
)
|
|
59
|
+
const markdownUrl = `${pageUrl}.md`
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
title,
|
|
63
|
+
description,
|
|
64
|
+
url: pageUrl,
|
|
65
|
+
markdownUrl,
|
|
66
|
+
section: p.type ? p.type.toUpperCase() : "GENERAL"
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// Fallback
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const title = llmsOpts.title || siteConfig.name || "Documentation"
|
|
76
|
+
const description =
|
|
77
|
+
llmsOpts.description || siteConfig.description || "Documentation index for AI agents."
|
|
78
|
+
|
|
79
|
+
const body = buildLlmsTxt({
|
|
80
|
+
site: siteUrl,
|
|
81
|
+
title,
|
|
82
|
+
description,
|
|
83
|
+
pages,
|
|
13
84
|
...llmsOpts
|
|
14
85
|
})
|
|
15
86
|
|
|
16
|
-
return new Response(
|
|
87
|
+
return new Response(body, {
|
|
17
88
|
headers: {
|
|
18
89
|
"Content-Type": "text/plain; charset=utf-8",
|
|
19
|
-
"Cache-Control": "public, max-age=3600,
|
|
90
|
+
"Cache-Control": "public, max-age=3600, s-maxage=86400"
|
|
20
91
|
}
|
|
21
92
|
})
|
|
22
93
|
}
|
package/src/routes/robots.txt.ts
CHANGED
|
@@ -1,13 +1,56 @@
|
|
|
1
1
|
import type { APIRoute } from "astro"
|
|
2
2
|
import { buildRobotsTxt } from "../robots.js"
|
|
3
|
-
import seoConfig from "virtual:rimelight-seo
|
|
3
|
+
import * as seoConfig from "virtual:rimelight-seo/config"
|
|
4
|
+
import * as siteConfigMod from "virtual:rimelight-seo/site-config"
|
|
5
|
+
import seoOptions from "virtual:rimelight-seo/options"
|
|
6
|
+
|
|
7
|
+
export const GET: APIRoute = ({ site, url }) => {
|
|
8
|
+
const siteUrl = site ? site.toString() : siteConfigMod.siteConfig?.url || url.origin
|
|
9
|
+
const sitemapURL = new URL("sitemap.xml", siteUrl).toString()
|
|
10
|
+
|
|
11
|
+
const defaultPrivatePrefixes = [
|
|
12
|
+
"/dashboard",
|
|
13
|
+
"/admin",
|
|
14
|
+
"/cms",
|
|
15
|
+
"/internal",
|
|
16
|
+
"/api",
|
|
17
|
+
"/dev",
|
|
18
|
+
"/og",
|
|
19
|
+
"/open-graph",
|
|
20
|
+
"/auth"
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
const customPrivatePrefixes = Array.isArray(seoConfig.PRIVATE_PATH_PREFIXES)
|
|
24
|
+
? seoConfig.PRIVATE_PATH_PREFIXES
|
|
25
|
+
: []
|
|
26
|
+
|
|
27
|
+
const privatePrefixes = Array.from(new Set([...defaultPrivatePrefixes, ...customPrivatePrefixes]))
|
|
28
|
+
|
|
29
|
+
const disallowPatterns = [
|
|
30
|
+
...privatePrefixes,
|
|
31
|
+
...privatePrefixes.map((p) => `/*${p.startsWith("/") ? p : `/${p}`}/`)
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
const robotsOpts = typeof seoOptions?.robots === "object" ? seoOptions.robots : {}
|
|
35
|
+
|
|
36
|
+
const defaultAgentRules = [
|
|
37
|
+
{
|
|
38
|
+
userAgent: "GPTBot",
|
|
39
|
+
disallow: privatePrefixes.map((p) => `${p.startsWith("/") ? p : `/${p}`}/`),
|
|
40
|
+
allow: ["/"]
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
userAgent: "ClaudeBot",
|
|
44
|
+
disallow: privatePrefixes.map((p) => `${p.startsWith("/") ? p : `/${p}`}/`),
|
|
45
|
+
allow: ["/"]
|
|
46
|
+
}
|
|
47
|
+
]
|
|
4
48
|
|
|
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
49
|
const robots = buildRobotsTxt({
|
|
9
50
|
sitemapUrl: sitemapURL,
|
|
51
|
+
disallow: disallowPatterns,
|
|
10
52
|
allow: ["/"],
|
|
53
|
+
userAgentRules: defaultAgentRules,
|
|
11
54
|
...robotsOpts
|
|
12
55
|
})
|
|
13
56
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type { APIRoute } from "astro"
|
|
2
|
+
import { buildRssXml } from "../rss.js"
|
|
3
|
+
import * as seoConfig from "virtual:rimelight-seo/config"
|
|
4
|
+
import * as siteConfigMod from "virtual:rimelight-seo/site-config"
|
|
5
|
+
import * as dbMod from "virtual:rimelight-seo/db"
|
|
6
|
+
import seoOptions from "virtual:rimelight-seo/options"
|
|
7
|
+
import type { RssItem, RssCollectionConfig } from "../types.js"
|
|
8
|
+
|
|
9
|
+
export const GET: APIRoute = async ({ site, url }) => {
|
|
10
|
+
const rssOpts = typeof seoOptions?.rss === "object" ? seoOptions.rss : {}
|
|
11
|
+
const siteConfig = siteConfigMod.siteConfig || {}
|
|
12
|
+
const siteUrl = site ? site.toString() : rssOpts.site || siteConfig.url || url.origin
|
|
13
|
+
|
|
14
|
+
let items: RssItem[] = []
|
|
15
|
+
|
|
16
|
+
if (Array.isArray(rssOpts.items)) {
|
|
17
|
+
items = rssOpts.items
|
|
18
|
+
} else if (typeof rssOpts.items === "function") {
|
|
19
|
+
items = await rssOpts.items()
|
|
20
|
+
} else if (typeof (seoConfig as any).rssEntries === "function") {
|
|
21
|
+
items = await (seoConfig as any).rssEntries()
|
|
22
|
+
} else {
|
|
23
|
+
const defaultCollections: (string | RssCollectionConfig)[] = [
|
|
24
|
+
"blog",
|
|
25
|
+
"posts",
|
|
26
|
+
"news",
|
|
27
|
+
"articles"
|
|
28
|
+
]
|
|
29
|
+
const rawCollections =
|
|
30
|
+
rssOpts.collections && rssOpts.collections.length > 0
|
|
31
|
+
? rssOpts.collections
|
|
32
|
+
: defaultCollections
|
|
33
|
+
|
|
34
|
+
const normalizedCollections: Array<{ name: string; pathPrefix: string }> = rawCollections.map(
|
|
35
|
+
(c: string | RssCollectionConfig) => {
|
|
36
|
+
if (typeof c === "string") {
|
|
37
|
+
return { name: c, pathPrefix: `/${c.replace(/^\/+|\/+$/g, "")}` }
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
name: c.name,
|
|
41
|
+
pathPrefix: c.pathPrefix
|
|
42
|
+
? `/${c.pathPrefix.replace(/^\/+|\/+$/g, "")}`
|
|
43
|
+
: `/${c.name.replace(/^\/+|\/+$/g, "")}`
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if (dbMod.db) {
|
|
49
|
+
try {
|
|
50
|
+
const { pages } = await import("#db/schema").catch(() => ({ pages: null }))
|
|
51
|
+
const { and, isNull } = await import("drizzle-orm")
|
|
52
|
+
if (pages) {
|
|
53
|
+
const collectionNames = normalizedCollections.map((c) => c.name)
|
|
54
|
+
const prefixMap = new Map(normalizedCollections.map((c) => [c.name, c.pathPrefix]))
|
|
55
|
+
|
|
56
|
+
const dbPages = await dbMod.db
|
|
57
|
+
.select()
|
|
58
|
+
.from(pages)
|
|
59
|
+
.where(and(isNull(pages.deletedAt)))
|
|
60
|
+
.catch(() => [])
|
|
61
|
+
|
|
62
|
+
const matchingPages = dbPages.filter((p: any) => collectionNames.includes(p.type))
|
|
63
|
+
for (const p of matchingPages) {
|
|
64
|
+
const prefix = prefixMap.get(p.type) || `/${p.type}`
|
|
65
|
+
const title =
|
|
66
|
+
typeof p.title === "string"
|
|
67
|
+
? p.title
|
|
68
|
+
: p.title?.en || (p.title && Object.values(p.title)[0]) || p.slug
|
|
69
|
+
const description =
|
|
70
|
+
typeof p.description === "string"
|
|
71
|
+
? p.description
|
|
72
|
+
: p.description?.en || (p.description && Object.values(p.description)[0]) || ""
|
|
73
|
+
items.push({
|
|
74
|
+
title,
|
|
75
|
+
description,
|
|
76
|
+
link: `${prefix}/${p.slug}`,
|
|
77
|
+
pubDate: p.postedAt || p.createdAt || new Date()
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
// DB lookup fallback
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (items.length === 0) {
|
|
87
|
+
try {
|
|
88
|
+
const { getCollection } = await import("astro:content")
|
|
89
|
+
for (const coll of normalizedCollections) {
|
|
90
|
+
try {
|
|
91
|
+
const collEntries = await (getCollection as any)(coll.name)
|
|
92
|
+
if (Array.isArray(collEntries)) {
|
|
93
|
+
for (const post of collEntries) {
|
|
94
|
+
items.push({
|
|
95
|
+
title: post.data?.title || post.id,
|
|
96
|
+
pubDate: post.data?.pubDate || post.data?.date || new Date(),
|
|
97
|
+
description: post.data?.description || post.data?.summary,
|
|
98
|
+
link: `${coll.pathPrefix}/${post.id}/`
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
// Collection not defined in content config, continue
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// No astro:content available
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const feedTitle = rssOpts.title || siteConfig.name || "RSS Feed"
|
|
113
|
+
const feedDesc = rssOpts.description || siteConfig.description || ""
|
|
114
|
+
|
|
115
|
+
const xml = buildRssXml({
|
|
116
|
+
title: feedTitle,
|
|
117
|
+
description: feedDesc,
|
|
118
|
+
site: siteUrl,
|
|
119
|
+
language: rssOpts.language || "en-US",
|
|
120
|
+
items
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
return new Response(xml, {
|
|
124
|
+
headers: {
|
|
125
|
+
"Content-Type": "application/xml; charset=utf-8",
|
|
126
|
+
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400"
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { APIRoute } from "astro"
|
|
2
|
+
import { buildSecurityTxt } from "../security.js"
|
|
3
|
+
import * as siteConfigMod from "virtual:rimelight-seo/site-config"
|
|
4
|
+
import seoOptions from "virtual:rimelight-seo/options"
|
|
5
|
+
|
|
6
|
+
export const prerender = false
|
|
7
|
+
|
|
8
|
+
export const GET: APIRoute = ({ site, url }) => {
|
|
9
|
+
const siteConfig = siteConfigMod.siteConfig || {}
|
|
10
|
+
const siteUrl = site ? site.toString() : siteConfig.url || url.origin
|
|
11
|
+
const secOpts = typeof seoOptions?.security === "object" ? seoOptions.security : {}
|
|
12
|
+
|
|
13
|
+
const defaultContact = siteConfig.email ? `mailto:${siteConfig.email}` : undefined
|
|
14
|
+
const contacts =
|
|
15
|
+
secOpts.contact || defaultContact || `mailto:security@${new URL(siteUrl).hostname}`
|
|
16
|
+
const canonical = secOpts.canonical || new URL("/.well-known/security.txt", siteUrl).toString()
|
|
17
|
+
|
|
18
|
+
const content = buildSecurityTxt({
|
|
19
|
+
contact: contacts,
|
|
20
|
+
canonical,
|
|
21
|
+
...secOpts
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
return new Response(content, {
|
|
25
|
+
headers: {
|
|
26
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
27
|
+
"Cache-Control": "public, max-age=86400, stale-while-revalidate=604800"
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { APIRoute } from "astro"
|
|
2
|
+
import { buildWebManifest } from "../manifest.js"
|
|
3
|
+
import * as siteConfigMod from "virtual:rimelight-seo/site-config"
|
|
4
|
+
import seoOptions from "virtual:rimelight-seo/options"
|
|
5
|
+
|
|
6
|
+
export const prerender = false
|
|
7
|
+
|
|
8
|
+
export const GET: APIRoute = () => {
|
|
9
|
+
const siteConfig = siteConfigMod.siteConfig || {}
|
|
10
|
+
const manifestOpts = typeof seoOptions?.manifest === "object" ? seoOptions.manifest : {}
|
|
11
|
+
|
|
12
|
+
const manifest = buildWebManifest({
|
|
13
|
+
name: manifestOpts.name || siteConfig.name || "Site",
|
|
14
|
+
shortName: manifestOpts.shortName || siteConfig.name || "Site",
|
|
15
|
+
description: manifestOpts.description || siteConfig.description || "",
|
|
16
|
+
backgroundColor:
|
|
17
|
+
manifestOpts.backgroundColor || siteConfig.branding?.colors?.backgroundColor || "#ffffff",
|
|
18
|
+
themeColor: manifestOpts.themeColor || siteConfig.branding?.colors?.themeColor || "#ffffff",
|
|
19
|
+
...manifestOpts
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
return new Response(JSON.stringify(manifest, null, 2), {
|
|
23
|
+
headers: {
|
|
24
|
+
"Content-Type": "application/manifest+json; charset=utf-8",
|
|
25
|
+
"Cache-Control": "public, max-age=86400, stale-while-revalidate=604800"
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
}
|
|
@@ -1,10 +1,37 @@
|
|
|
1
1
|
import type { APIRoute } from "astro"
|
|
2
|
-
import { buildSitemapXml } from "../sitemap.js"
|
|
3
|
-
import seoConfig from "virtual:rimelight-seo
|
|
2
|
+
import { buildSitemapUrls, buildSitemapXml } from "../sitemap.js"
|
|
3
|
+
import * as seoConfig from "virtual:rimelight-seo/config"
|
|
4
|
+
import * as siteConfigMod from "virtual:rimelight-seo/site-config"
|
|
5
|
+
import seoOptions from "virtual:rimelight-seo/options"
|
|
6
|
+
|
|
7
|
+
export const GET: APIRoute = async ({ site, url }) => {
|
|
8
|
+
const sitemapOpts = typeof seoOptions?.sitemap === "object" ? seoOptions.sitemap : {}
|
|
9
|
+
const siteUrl = site ? site.toString() : siteConfigMod.siteConfig?.url || url.origin
|
|
10
|
+
|
|
11
|
+
let entries: any[] = []
|
|
12
|
+
if (typeof sitemapOpts.entries === "function") {
|
|
13
|
+
entries = await sitemapOpts.entries()
|
|
14
|
+
} else if (typeof seoConfig.seoEntries === "function") {
|
|
15
|
+
entries = await seoConfig.seoEntries()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const locales =
|
|
19
|
+
sitemapOpts.locales ||
|
|
20
|
+
seoConfig.SEO_LOCALES ||
|
|
21
|
+
(siteUrl
|
|
22
|
+
? {
|
|
23
|
+
site: siteUrl.replace(/\/$/, ""),
|
|
24
|
+
locales: { en: "en-US" },
|
|
25
|
+
defaultLocale: "en",
|
|
26
|
+
trailingSlash: "never" as const
|
|
27
|
+
}
|
|
28
|
+
: undefined)
|
|
29
|
+
|
|
30
|
+
let urls = sitemapOpts.urls || []
|
|
31
|
+
if (urls.length === 0 && entries.length > 0 && locales) {
|
|
32
|
+
urls = buildSitemapUrls(entries, locales)
|
|
33
|
+
}
|
|
4
34
|
|
|
5
|
-
export const GET: APIRoute = () => {
|
|
6
|
-
const sitemapOpts = typeof seoConfig?.sitemap === "object" ? seoConfig.sitemap : {}
|
|
7
|
-
const urls = sitemapOpts.urls || []
|
|
8
35
|
const xml = buildSitemapXml(urls)
|
|
9
36
|
|
|
10
37
|
return new Response(xml, {
|
package/src/rss.test.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test"
|
|
2
|
+
import { buildRssXml } from "./rss.js"
|
|
3
|
+
|
|
4
|
+
describe("buildRssXml", () => {
|
|
5
|
+
it("should generate valid RSS XML with channel and items", () => {
|
|
6
|
+
const xml = buildRssXml({
|
|
7
|
+
title: "My Site",
|
|
8
|
+
description: "My site description",
|
|
9
|
+
site: "https://example.com",
|
|
10
|
+
items: [
|
|
11
|
+
{
|
|
12
|
+
title: "Post 1 & More",
|
|
13
|
+
link: "/blog/post-1",
|
|
14
|
+
pubDate: new Date("2026-01-01T00:00:00Z"),
|
|
15
|
+
description: "Hello & welcome"
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
expect(xml).toContain('<rss version="2.0"')
|
|
21
|
+
expect(xml).toContain("<title>My Site</title>")
|
|
22
|
+
expect(xml).toContain("<description>My site description</description>")
|
|
23
|
+
expect(xml).toContain("<link>https://example.com</link>")
|
|
24
|
+
expect(xml).toContain("<title>Post 1 & More</title>")
|
|
25
|
+
expect(xml).toContain("<link>https://example.com/blog/post-1</link>")
|
|
26
|
+
expect(xml).toContain("<description>Hello & welcome</description>")
|
|
27
|
+
})
|
|
28
|
+
})
|
package/src/rss.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { RssFeedOptions } from "./types.js"
|
|
2
|
+
|
|
3
|
+
export function escapeXml(str: string): string {
|
|
4
|
+
return str
|
|
5
|
+
.replace(/&/g, "&")
|
|
6
|
+
.replace(/</g, "<")
|
|
7
|
+
.replace(/>/g, ">")
|
|
8
|
+
.replace(/"/g, """)
|
|
9
|
+
.replace(/'/g, "'")
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Builds standard RSS 2.0 feed XML document with Atom namespace link.
|
|
14
|
+
*/
|
|
15
|
+
export function buildRssXml(options: RssFeedOptions): string {
|
|
16
|
+
const siteUrl = (options.site || "").replace(/\/$/, "")
|
|
17
|
+
const feedUrl = options.feedUrl || `${siteUrl}/rss.xml`
|
|
18
|
+
const language = options.language || "en-US"
|
|
19
|
+
const lastBuildDate = new Date().toUTCString()
|
|
20
|
+
|
|
21
|
+
const itemXmls = (options.items || []).map((item) => {
|
|
22
|
+
const itemLink =
|
|
23
|
+
item.link.startsWith("http://") || item.link.startsWith("https://")
|
|
24
|
+
? item.link
|
|
25
|
+
: `${siteUrl}${item.link.startsWith("/") ? "" : "/"}${item.link}`
|
|
26
|
+
const pubDate = new Date(item.pubDate).toUTCString()
|
|
27
|
+
const guid = item.guid || itemLink
|
|
28
|
+
|
|
29
|
+
const lines: string[] = [
|
|
30
|
+
" <item>",
|
|
31
|
+
` <title>${escapeXml(item.title)}</title>`,
|
|
32
|
+
` <link>${escapeXml(itemLink)}</link>`,
|
|
33
|
+
` <guid isPermaLink="${guid.startsWith("http") ? "true" : "false"}">${escapeXml(guid)}</guid>`,
|
|
34
|
+
` <pubDate>${pubDate}</pubDate>`
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
if (item.description) {
|
|
38
|
+
lines.push(` <description>${escapeXml(item.description)}</description>`)
|
|
39
|
+
}
|
|
40
|
+
if (item.author) {
|
|
41
|
+
lines.push(` <author>${escapeXml(item.author)}</author>`)
|
|
42
|
+
}
|
|
43
|
+
if (item.content) {
|
|
44
|
+
lines.push(` <content:encoded><![CDATA[${item.content}]]></content:encoded>`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
lines.push(" </item>")
|
|
48
|
+
return lines.join("\n")
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
52
|
+
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
|
|
53
|
+
<channel>
|
|
54
|
+
<title>${escapeXml(options.title || "")}</title>
|
|
55
|
+
<description>${escapeXml(options.description || "")}</description>
|
|
56
|
+
<link>${escapeXml(siteUrl)}</link>
|
|
57
|
+
<language>${escapeXml(language)}</language>
|
|
58
|
+
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
|
59
|
+
<atom:link href="${escapeXml(feedUrl)}" rel="self" type="application/rss+xml"/>
|
|
60
|
+
${itemXmls.join("\n")}
|
|
61
|
+
</channel>
|
|
62
|
+
</rss>`
|
|
63
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, it, expect } from "vite-plus/test"
|
|
2
|
+
import { buildSecurityTxt } from "./security.js"
|
|
3
|
+
|
|
4
|
+
describe("buildSecurityTxt", () => {
|
|
5
|
+
it("generates RFC 9116 compliant security.txt with single contact and default expires", () => {
|
|
6
|
+
const output = buildSecurityTxt({
|
|
7
|
+
contact: "mailto:security@example.com"
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
expect(output).toContain("Contact: mailto:security@example.com")
|
|
11
|
+
expect(output).toMatch(/Expires: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it("handles multiple contacts and explicit date expires", () => {
|
|
15
|
+
const expiry = new Date("2027-12-31T23:59:59.000Z")
|
|
16
|
+
const output = buildSecurityTxt({
|
|
17
|
+
contact: ["mailto:security@example.com", "https://example.com/security/report"],
|
|
18
|
+
expires: expiry,
|
|
19
|
+
preferredLanguages: "en, pt",
|
|
20
|
+
canonical: "https://example.com/.well-known/security.txt",
|
|
21
|
+
policy: "https://example.com/legal/security",
|
|
22
|
+
hiring: "https://example.com/careers",
|
|
23
|
+
acknowledgments: "https://example.com/security/acknowledgments"
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
expect(output).toContain("Contact: mailto:security@example.com")
|
|
27
|
+
expect(output).toContain("Contact: https://example.com/security/report")
|
|
28
|
+
expect(output).toContain("Expires: 2027-12-31T23:59:59.000Z")
|
|
29
|
+
expect(output).toContain("Preferred-Languages: en, pt")
|
|
30
|
+
expect(output).toContain("Canonical: https://example.com/.well-known/security.txt")
|
|
31
|
+
expect(output).toContain("Policy: https://example.com/legal/security")
|
|
32
|
+
expect(output).toContain("Hiring: https://example.com/careers")
|
|
33
|
+
expect(output).toContain("Acknowledgments: https://example.com/security/acknowledgments")
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("formats encryption and csaf directives", () => {
|
|
37
|
+
const output = buildSecurityTxt({
|
|
38
|
+
contact: "mailto:security@example.com",
|
|
39
|
+
encryption: "https://example.com/pgp-key.txt",
|
|
40
|
+
csaf: "https://example.com/.well-known/csaf/provider-metadata.json"
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
expect(output).toContain("Encryption: https://example.com/pgp-key.txt")
|
|
44
|
+
expect(output).toContain("CSAF: https://example.com/.well-known/csaf/provider-metadata.json")
|
|
45
|
+
})
|
|
46
|
+
})
|
package/src/security.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { SecurityTxtOptions } from "./types.js"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build RFC 9116 compliant security.txt content. https://www.rfc-editor.org/rfc/rfc9116
|
|
5
|
+
*/
|
|
6
|
+
export function buildSecurityTxt(options: SecurityTxtOptions): string {
|
|
7
|
+
const lines: string[] = []
|
|
8
|
+
|
|
9
|
+
// Contact (REQUIRED in RFC 9116: at least one)
|
|
10
|
+
const contacts = Array.isArray(options.contact)
|
|
11
|
+
? options.contact
|
|
12
|
+
: options.contact
|
|
13
|
+
? [options.contact]
|
|
14
|
+
: []
|
|
15
|
+
|
|
16
|
+
for (const contact of contacts) {
|
|
17
|
+
lines.push(`Contact: ${contact}`)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Expires (REQUIRED in RFC 9116)
|
|
21
|
+
let expiresStr: string
|
|
22
|
+
if (options.expires instanceof Date) {
|
|
23
|
+
expiresStr = options.expires.toISOString()
|
|
24
|
+
} else if (typeof options.expires === "string" && options.expires.trim().length > 0) {
|
|
25
|
+
expiresStr = options.expires
|
|
26
|
+
} else {
|
|
27
|
+
// Default to 1 year in the future from generation
|
|
28
|
+
const oneYearFromNow = new Date()
|
|
29
|
+
oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1)
|
|
30
|
+
expiresStr = oneYearFromNow.toISOString()
|
|
31
|
+
}
|
|
32
|
+
lines.push(`Expires: ${expiresStr}`)
|
|
33
|
+
|
|
34
|
+
// Encryption (OPTIONAL)
|
|
35
|
+
if (options.encryption) {
|
|
36
|
+
const encList = Array.isArray(options.encryption) ? options.encryption : [options.encryption]
|
|
37
|
+
for (const enc of encList) {
|
|
38
|
+
lines.push(`Encryption: ${enc}`)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Acknowledgments (OPTIONAL)
|
|
43
|
+
if (options.acknowledgments) {
|
|
44
|
+
const acks = Array.isArray(options.acknowledgments)
|
|
45
|
+
? options.acknowledgments
|
|
46
|
+
: [options.acknowledgments]
|
|
47
|
+
for (const ack of acks) {
|
|
48
|
+
lines.push(`Acknowledgments: ${ack}`)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Preferred-Languages (OPTIONAL)
|
|
53
|
+
if (options.preferredLanguages) {
|
|
54
|
+
lines.push(`Preferred-Languages: ${options.preferredLanguages}`)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Canonical (OPTIONAL)
|
|
58
|
+
if (options.canonical) {
|
|
59
|
+
lines.push(`Canonical: ${options.canonical}`)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Policy (OPTIONAL)
|
|
63
|
+
if (options.policy) {
|
|
64
|
+
lines.push(`Policy: ${options.policy}`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Hiring (OPTIONAL)
|
|
68
|
+
if (options.hiring) {
|
|
69
|
+
lines.push(`Hiring: ${options.hiring}`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// CSAF (OPTIONAL)
|
|
73
|
+
if (options.csaf) {
|
|
74
|
+
lines.push(`CSAF: ${options.csaf}`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return lines.join("\n") + "\n"
|
|
78
|
+
}
|