@rimelight/seo 0.0.6 → 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 +6 -2
- package/dist/index.mjs +5 -1
- package/dist/integration.mjs +182 -0
- package/dist/manifest.d.mts +7 -0
- package/dist/manifest.mjs +44 -0
- package/dist/og.mjs +1 -1
- 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.txt.mjs +1 -1
- package/dist/routes/robots.txt.mjs +1 -1
- package/dist/routes/rss.xml.mjs +48 -20
- 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 +1 -1
- package/dist/security.d.mts +7 -0
- package/dist/security.mjs +34 -0
- package/dist/types.d.mts +191 -8
- package/package.json +23 -2
- 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 +56 -33
- package/src/index.ts +18 -1
- package/src/integration.ts +353 -172
- package/src/manifest.test.ts +41 -0
- package/src/manifest.ts +59 -0
- package/src/og.ts +1 -9
- 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 -77
- package/src/routes/llms.txt.ts +93 -90
- package/src/routes/robots.txt.ts +63 -63
- package/src/routes/rss.xml.ts +129 -87
- package/src/routes/security.txt.ts +30 -0
- package/src/routes/site.webmanifest.ts +28 -0
- package/src/routes/sitemap.xml.ts +43 -43
- package/src/rss.test.ts +28 -28
- package/src/rss.ts +63 -63
- package/src/security.test.ts +46 -0
- package/src/security.ts +78 -0
- package/src/types.ts +564 -367
package/src/routes/rss.xml.ts
CHANGED
|
@@ -1,87 +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 } 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() :
|
|
13
|
-
|
|
14
|
-
let items: RssItem[] = []
|
|
15
|
-
|
|
16
|
-
if (Array.isArray(rssOpts.items)) {
|
|
17
|
-
items = rssOpts.items
|
|
18
|
-
} else if (typeof
|
|
19
|
-
items = await
|
|
20
|
-
} else if (
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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,43 +1,43 @@
|
|
|
1
|
-
import type { APIRoute } from "astro"
|
|
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() :
|
|
10
|
-
|
|
11
|
-
let entries = []
|
|
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
|
-
}
|
|
34
|
-
|
|
35
|
-
const xml = buildSitemapXml(urls)
|
|
36
|
-
|
|
37
|
-
return new Response(xml, {
|
|
38
|
-
headers: {
|
|
39
|
-
"Content-Type": "application/xml; charset=utf-8",
|
|
40
|
-
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400"
|
|
41
|
-
}
|
|
42
|
-
})
|
|
43
|
-
}
|
|
1
|
+
import type { APIRoute } from "astro"
|
|
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
|
+
}
|
|
34
|
+
|
|
35
|
+
const xml = buildSitemapXml(urls)
|
|
36
|
+
|
|
37
|
+
return new Response(xml, {
|
|
38
|
+
headers: {
|
|
39
|
+
"Content-Type": "application/xml; charset=utf-8",
|
|
40
|
+
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400"
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
}
|
package/src/rss.test.ts
CHANGED
|
@@ -1,28 +1,28 @@
|
|
|
1
|
-
import { describe, expect, it } from "
|
|
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(
|
|
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
|
-
})
|
|
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
CHANGED
|
@@ -1,63 +1,63 @@
|
|
|
1
|
-
import type {
|
|
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
|
-
}
|
|
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
|
+
})
|