@rimelight/seo 0.0.5 → 0.0.6

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.
@@ -0,0 +1,77 @@
1
+ import type { APIRoute } from "astro"
2
+ import * as siteConfigMod from "virtual:rimelight-seo/site-config"
3
+ import * as dbMod from "virtual:rimelight-seo/db"
4
+ import * as corpusMod from "virtual:rimelight-seo/corpus"
5
+
6
+ export const prerender = false
7
+
8
+ export const GET: APIRoute = async ({ site, url }) => {
9
+ const siteConfig = siteConfigMod.siteConfig || {}
10
+ const siteUrl = site ? site.toString() : (siteConfig.url || `${url.protocol}//${url.host}`)
11
+ const title = siteConfig.name
12
+ ? `${siteConfig.name} Full Documentation Corpus`
13
+ : "Full Documentation Corpus"
14
+ const description = "Complete single-corpus documentation for AI agents and LLM ingest."
15
+
16
+ // 1. Try corpusPages if available
17
+ if (Array.isArray(corpusMod.corpusPages) && corpusMod.corpusPages.length > 0) {
18
+ const sections = new Map<string, string[]>()
19
+ for (const p of corpusMod.corpusPages) {
20
+ const key = (p.section || "GENERAL").toUpperCase()
21
+ if (!sections.has(key)) sections.set(key, [])
22
+ const slug = p.slug === "index" ? "" : p.slug
23
+ const pageUrl = `${siteUrl.replace(/\/$/, "")}/${slug}`.replace(/\/+$/, "")
24
+ sections.get(key)!.push(`### ${p.title}\nURL: ${pageUrl}\n${p.description || ""}`)
25
+ }
26
+
27
+ const body = [
28
+ `# ${title}`,
29
+ "",
30
+ description,
31
+ "",
32
+ ...Array.from(sections.entries()).flatMap(([section, pages]) => [
33
+ `## ${section}`,
34
+ "",
35
+ ...pages,
36
+ ""
37
+ ])
38
+ ].join("\n")
39
+
40
+ return new Response(body, {
41
+ headers: {
42
+ "Content-Type": "text/plain; charset=utf-8",
43
+ "Cache-Control": "public, max-age=3600, s-maxage=86400"
44
+ }
45
+ })
46
+ }
47
+
48
+ // 2. Try CMS renderCorpusMarkdown if db exists
49
+ if (dbMod.db) {
50
+ try {
51
+ const { renderCorpusMarkdown } = await import("@rimelight/cms")
52
+ const body = await renderCorpusMarkdown(dbMod.db, {
53
+ type: "doc",
54
+ locale: "en",
55
+ siteUrl,
56
+ title,
57
+ description
58
+ })
59
+
60
+ return new Response(body, {
61
+ headers: {
62
+ "Content-Type": "text/plain; charset=utf-8",
63
+ "Cache-Control": "public, max-age=3600, s-maxage=86400"
64
+ }
65
+ })
66
+ } catch {
67
+ // Fallback
68
+ }
69
+ }
70
+
71
+ return new Response(`# ${title}\n\n${description}\n`, {
72
+ headers: {
73
+ "Content-Type": "text/plain; charset=utf-8",
74
+ "Cache-Control": "public, max-age=3600, s-maxage=86400"
75
+ }
76
+ })
77
+ }
@@ -1,22 +1,90 @@
1
- import type { APIRoute } from "astro"
2
- import { buildLlmsTxt } from "../llms.js"
3
- import seoConfig from "virtual:rimelight-seo-config"
4
-
5
- export const GET: APIRoute = ({ site }) => {
6
- const llmsOpts = typeof seoConfig?.llms === "object" ? seoConfig.llms : {}
7
- const siteStr = site ? site.toString().replace(/\/$/, "") : ""
8
- const hostname = site ? new URL(site).hostname : "Site"
9
- const content = buildLlmsTxt({
10
- site: siteStr,
11
- title: hostname,
12
- pages: [],
13
- ...llmsOpts
14
- })
15
-
16
- return new Response(content, {
17
- headers: {
18
- "Content-Type": "text/plain; charset=utf-8",
19
- "Cache-Control": "public, max-age=3600, stale-while-revalidate=86400"
20
- }
21
- })
22
- }
1
+ import type { APIRoute } from "astro"
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
+ const markdownUrl = `${pageUrl}.md`
57
+
58
+ return {
59
+ title,
60
+ description,
61
+ url: pageUrl,
62
+ markdownUrl,
63
+ section: p.type ? p.type.toUpperCase() : "GENERAL"
64
+ }
65
+ })
66
+ }
67
+ } catch {
68
+ // Fallback
69
+ }
70
+ }
71
+
72
+ const title = llmsOpts.title || siteConfig.name || "Documentation"
73
+ const description =
74
+ llmsOpts.description || siteConfig.description || "Documentation index for AI agents."
75
+
76
+ const body = buildLlmsTxt({
77
+ site: siteUrl,
78
+ title,
79
+ description,
80
+ pages,
81
+ ...llmsOpts
82
+ })
83
+
84
+ return new Response(body, {
85
+ headers: {
86
+ "Content-Type": "text/plain; charset=utf-8",
87
+ "Cache-Control": "public, max-age=3600, s-maxage=86400"
88
+ }
89
+ })
90
+ }
@@ -1,20 +1,63 @@
1
- import type { APIRoute } from "astro"
2
- import { buildRobotsTxt } from "../robots.js"
3
- import seoConfig from "virtual:rimelight-seo-config"
4
-
5
- export const GET: APIRoute = ({ site }) => {
6
- const sitemapURL = site ? new URL("sitemap.xml", site).toString() : ""
7
- const robotsOpts = typeof seoConfig?.robots === "object" ? seoConfig.robots : {}
8
- const robots = buildRobotsTxt({
9
- sitemapUrl: sitemapURL,
10
- allow: ["/"],
11
- ...robotsOpts
12
- })
13
-
14
- return new Response(robots, {
15
- headers: {
16
- "Content-Type": "text/plain; charset=utf-8",
17
- "Cache-Control": "public, max-age=3600, stale-while-revalidate=86400"
18
- }
19
- })
20
- }
1
+ import type { APIRoute } from "astro"
2
+ import { buildRobotsTxt } from "../robots.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 = ({ 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
+ ]
48
+
49
+ const robots = buildRobotsTxt({
50
+ sitemapUrl: sitemapURL,
51
+ disallow: disallowPatterns,
52
+ allow: ["/"],
53
+ userAgentRules: defaultAgentRules,
54
+ ...robotsOpts
55
+ })
56
+
57
+ return new Response(robots, {
58
+ headers: {
59
+ "Content-Type": "text/plain; charset=utf-8",
60
+ "Cache-Control": "public, max-age=3600, stale-while-revalidate=86400"
61
+ }
62
+ })
63
+ }
@@ -0,0 +1,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 } 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,16 +1,43 @@
1
- import type { APIRoute } from "astro"
2
- import { buildSitemapXml } from "../sitemap.js"
3
- import seoConfig from "virtual:rimelight-seo-config"
4
-
5
- export const GET: APIRoute = () => {
6
- const sitemapOpts = typeof seoConfig?.sitemap === "object" ? seoConfig.sitemap : {}
7
- const urls = sitemapOpts.urls || []
8
- const xml = buildSitemapXml(urls)
9
-
10
- return new Response(xml, {
11
- headers: {
12
- "Content-Type": "application/xml; charset=utf-8",
13
- "Cache-Control": "public, max-age=3600, stale-while-revalidate=86400"
14
- }
15
- })
16
- }
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
+ }
@@ -0,0 +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
+ })
package/src/rss.ts ADDED
@@ -0,0 +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
+ }