@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.
Files changed (66) hide show
  1. package/dist/assets.d.mts +39 -0
  2. package/dist/assets.mjs +96 -0
  3. package/dist/catalog.d.mts +7 -0
  4. package/dist/catalog.mjs +10 -0
  5. package/dist/index.d.mts +6 -2
  6. package/dist/index.mjs +5 -1
  7. package/dist/integration.mjs +182 -0
  8. package/dist/manifest.d.mts +7 -0
  9. package/dist/manifest.mjs +44 -0
  10. package/dist/og.mjs +1 -1
  11. package/dist/routes/api-catalog.d.mts +5 -0
  12. package/dist/routes/api-catalog.mjs +38 -0
  13. package/dist/routes/apple-touch-icon.png.d.mts +5 -0
  14. package/dist/routes/apple-touch-icon.png.mjs +17 -0
  15. package/dist/routes/favicon.ico.d.mts +5 -0
  16. package/dist/routes/favicon.ico.mjs +14 -0
  17. package/dist/routes/favicon.svg.d.mts +5 -0
  18. package/dist/routes/favicon.svg.mjs +12 -0
  19. package/dist/routes/icon-192.png.d.mts +5 -0
  20. package/dist/routes/icon-192.png.mjs +17 -0
  21. package/dist/routes/icon-512.png.d.mts +5 -0
  22. package/dist/routes/icon-512.png.mjs +17 -0
  23. package/dist/routes/icon-maskable-512.png.d.mts +5 -0
  24. package/dist/routes/icon-maskable-512.png.mjs +18 -0
  25. package/dist/routes/llms.txt.mjs +1 -1
  26. package/dist/routes/robots.txt.mjs +1 -1
  27. package/dist/routes/rss.xml.mjs +48 -20
  28. package/dist/routes/security.txt.d.mts +5 -0
  29. package/dist/routes/security.txt.mjs +24 -0
  30. package/dist/routes/site.webmanifest.d.mts +5 -0
  31. package/dist/routes/site.webmanifest.mjs +23 -0
  32. package/dist/routes/sitemap.xml.mjs +1 -1
  33. package/dist/security.d.mts +7 -0
  34. package/dist/security.mjs +34 -0
  35. package/dist/types.d.mts +191 -8
  36. package/package.json +23 -2
  37. package/src/assets.test.ts +70 -0
  38. package/src/assets.ts +137 -0
  39. package/src/catalog.test.ts +26 -0
  40. package/src/catalog.ts +11 -0
  41. package/src/components/SEOHead.astro +1 -1
  42. package/src/env.d.ts +56 -33
  43. package/src/index.ts +18 -1
  44. package/src/integration.ts +353 -172
  45. package/src/manifest.test.ts +41 -0
  46. package/src/manifest.ts +59 -0
  47. package/src/og.ts +1 -9
  48. package/src/routes/api-catalog.ts +55 -0
  49. package/src/routes/apple-touch-icon.png.ts +19 -0
  50. package/src/routes/favicon.ico.ts +19 -0
  51. package/src/routes/favicon.svg.ts +17 -0
  52. package/src/routes/icon-192.png.ts +19 -0
  53. package/src/routes/icon-512.png.ts +19 -0
  54. package/src/routes/icon-maskable-512.png.ts +24 -0
  55. package/src/routes/llms-full.txt.ts +77 -77
  56. package/src/routes/llms.txt.ts +93 -90
  57. package/src/routes/robots.txt.ts +63 -63
  58. package/src/routes/rss.xml.ts +129 -87
  59. package/src/routes/security.txt.ts +30 -0
  60. package/src/routes/site.webmanifest.ts +28 -0
  61. package/src/routes/sitemap.xml.ts +43 -43
  62. package/src/rss.test.ts +28 -28
  63. package/src/rss.ts +63 -63
  64. package/src/security.test.ts +46 -0
  65. package/src/security.ts +78 -0
  66. package/src/types.ts +564 -367
@@ -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() : (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 (seoConfig as any).rssEntries === "function") {
19
- items = await (seoConfig as any).rssEntries()
20
- } else if (dbMod.db) {
21
- try {
22
- const { pages } = await import("#db/schema").catch(() => ({ pages: null }))
23
- const { and, isNull, sql } = await import("drizzle-orm")
24
- if (pages) {
25
- const blogPages = await dbMod.db
26
- .select()
27
- .from(pages)
28
- .where(and(sql`${pages.type} = 'blog'`, isNull(pages.deletedAt)))
29
- .catch(() => [])
30
-
31
- items = blogPages.map((p: any) => {
32
- const title =
33
- typeof p.title === "string"
34
- ? p.title
35
- : p.title?.en || (p.title && Object.values(p.title)[0]) || p.slug
36
- const description =
37
- typeof p.description === "string"
38
- ? p.description
39
- : p.description?.en || (p.description && Object.values(p.description)[0]) || ""
40
- return {
41
- title,
42
- description,
43
- link: `/blog/${p.slug}`,
44
- pubDate: p.postedAt || p.createdAt || new Date()
45
- }
46
- })
47
- }
48
- } catch {
49
- // Fallback
50
- }
51
- }
52
-
53
- if (items.length === 0) {
54
- try {
55
- const { getCollection } = await import("astro:content")
56
- const blog = await getCollection("blog")
57
- if (Array.isArray(blog)) {
58
- items = blog.map((post: any) => ({
59
- title: post.data?.title || post.id,
60
- pubDate: post.data?.pubDate || new Date(),
61
- description: post.data?.description,
62
- link: `/blog/${post.id}/`
63
- }))
64
- }
65
- } catch {
66
- // No collections found
67
- }
68
- }
69
-
70
- const feedTitle = rssOpts.title || siteConfig.name || "RSS Feed"
71
- const feedDesc = rssOpts.description || siteConfig.description || ""
72
-
73
- const xml = buildRssXml({
74
- title: feedTitle,
75
- description: feedDesc,
76
- site: siteUrl,
77
- language: rssOpts.language || "en-US",
78
- items
79
- })
80
-
81
- return new Response(xml, {
82
- headers: {
83
- "Content-Type": "application/xml; charset=utf-8",
84
- "Cache-Control": "public, max-age=3600, stale-while-revalidate=86400"
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() : (siteConfigMod.siteConfig?.url || url.origin)
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 "vitest"
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 &amp; More</title>")
25
- expect(xml).toContain("<link>https://example.com/blog/post-1</link>")
26
- expect(xml).toContain("<description>Hello &amp; 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 &amp; More</title>")
25
+ expect(xml).toContain("<link>https://example.com/blog/post-1</link>")
26
+ expect(xml).toContain("<description>Hello &amp; welcome</description>")
27
+ })
28
+ })
package/src/rss.ts CHANGED
@@ -1,63 +1,63 @@
1
- import type { RssItem, RssFeedOptions } from "./types.js"
2
-
3
- export function escapeXml(str: string): string {
4
- return str
5
- .replace(/&/g, "&amp;")
6
- .replace(/</g, "&lt;")
7
- .replace(/>/g, "&gt;")
8
- .replace(/"/g, "&quot;")
9
- .replace(/'/g, "&apos;")
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, "&amp;")
6
+ .replace(/</g, "&lt;")
7
+ .replace(/>/g, "&gt;")
8
+ .replace(/"/g, "&quot;")
9
+ .replace(/'/g, "&apos;")
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
+ })