@rimelight/seo 0.0.4 → 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.
Files changed (46) hide show
  1. package/dist/index.d.mts +8 -38
  2. package/dist/index.mjs +8 -175
  3. package/dist/integration.d.mts +6 -0
  4. package/dist/integration.mjs +101 -0
  5. package/dist/llms-BVLxzjr-.d.mts +41 -0
  6. package/dist/llms.d.mts +2 -0
  7. package/dist/llms.mjs +75 -0
  8. package/dist/meta.d.mts +42 -0
  9. package/dist/meta.mjs +66 -0
  10. package/dist/og.d.mts +117 -0
  11. package/dist/og.mjs +302 -0
  12. package/dist/robots.d.mts +3 -4
  13. package/dist/routes/llms-full.txt.d.mts +5 -0
  14. package/dist/routes/llms-full.txt.mjs +57 -0
  15. package/dist/routes/llms.txt.d.mts +5 -0
  16. package/dist/routes/llms.txt.mjs +68 -0
  17. package/dist/routes/robots.txt.d.mts +4 -0
  18. package/dist/routes/robots.txt.mjs +46 -0
  19. package/dist/routes/rss.xml.d.mts +4 -0
  20. package/dist/routes/rss.xml.mjs +51 -0
  21. package/dist/routes/sitemap.xml.d.mts +4 -0
  22. package/dist/routes/sitemap.xml.mjs +27 -0
  23. package/dist/rss.d.mts +8 -0
  24. package/dist/rss.mjs +44 -0
  25. package/dist/schema-C8v69blQ.d.mts +28 -0
  26. package/dist/schema.d.mts +2 -0
  27. package/dist/schema.mjs +52 -0
  28. package/dist/sitemap.d.mts +6 -7
  29. package/dist/types.d.mts +354 -2
  30. package/package.json +24 -5
  31. package/src/components/SEOHead.astro +65 -18
  32. package/src/env.d.ts +33 -3
  33. package/src/index.ts +19 -1
  34. package/src/integration.ts +172 -0
  35. package/src/meta.test.ts +25 -1
  36. package/src/meta.ts +32 -2
  37. package/src/og.ts +444 -0
  38. package/src/routes/llms-full.txt.ts +77 -0
  39. package/src/routes/llms.txt.ts +90 -0
  40. package/src/routes/robots.txt.ts +63 -0
  41. package/src/routes/rss.xml.ts +87 -0
  42. package/src/routes/sitemap.xml.ts +43 -0
  43. package/src/rss.test.ts +28 -0
  44. package/src/rss.ts +63 -0
  45. package/src/types.ts +367 -242
  46. package/dist/types-e7gqYZwc.d.mts +0 -305
@@ -1,11 +1,12 @@
1
1
  ---
2
- import type { HeadProps } from "../types.js"
2
+ import type { HeadProps, OpenGraphMeta, TwitterMeta } from "../types.js"
3
3
  import {
4
4
  buildCanonicalUrl,
5
5
  buildPageTitle,
6
6
  buildSafeDescription,
7
7
  buildRobotsMetaContent,
8
- buildWebSiteSchema
8
+ buildWebSiteSchema,
9
+ deepMerge
9
10
  } from "../meta.js"
10
11
  import { buildArticleSchema, buildBreadcrumbSchema } from "../schema.js"
11
12
 
@@ -29,6 +30,10 @@ const {
29
30
  breadcrumbs,
30
31
  markdownUrl,
31
32
  versionAlternates = [],
33
+ absolute = false,
34
+ twitterCard,
35
+ openGraph,
36
+ twitter,
32
37
  } = Astro.props as HeadProps
33
38
 
34
39
  // Resolve defaults from config if available
@@ -50,7 +55,8 @@ const displayTitle = buildPageTitle({
50
55
  ...(title !== undefined && { title }),
51
56
  siteName,
52
57
  titleTemplate,
53
- ...(is404 !== undefined && { is404 })
58
+ ...(is404 !== undefined && { is404 }),
59
+ ...(absolute !== undefined && { absolute })
54
60
  })
55
61
 
56
62
  const finalDescription = description || siteDescription
@@ -72,6 +78,47 @@ const robotsContent = buildRobotsMetaContent({
72
78
  is404,
73
79
  ...(robots !== undefined && { override: robots })
74
80
  })
81
+
82
+ // Deep-merged OpenGraph
83
+ const defaultOpenGraph: OpenGraphMeta = {
84
+ type: ogType,
85
+ locale: ogLocale,
86
+ url: Astro.url.href,
87
+ title: title || siteName,
88
+ ...(safeDescription && { description: safeDescription }),
89
+ ...(absoluteOgImage && { images: [{ src: absoluteOgImage, alt: finalOgImage?.alt || siteName }] }),
90
+ siteName,
91
+ ...config?.openGraph,
92
+ }
93
+ const resolvedOpenGraph = deepMerge(defaultOpenGraph, openGraph || {})
94
+ if (ogImage) {
95
+ resolvedOpenGraph.images = ogImage
96
+ }
97
+ const ogImgUrl = resolvedOpenGraph.images
98
+ ? (Array.isArray(resolvedOpenGraph.images) ? resolvedOpenGraph.images[0]?.src : (typeof resolvedOpenGraph.images === 'string' ? resolvedOpenGraph.images : resolvedOpenGraph.images.src))
99
+ : absoluteOgImage
100
+ const ogImgAlt = resolvedOpenGraph.images
101
+ ? (Array.isArray(resolvedOpenGraph.images) ? resolvedOpenGraph.images[0]?.alt : (typeof resolvedOpenGraph.images === 'object' && 'alt' in resolvedOpenGraph.images ? resolvedOpenGraph.images.alt : finalOgImage?.alt || siteName))
102
+ : finalOgImage?.alt || siteName
103
+
104
+ const absoluteResolvedOgImage = ogImgUrl ? new URL(ogImgUrl, Astro.site ?? Astro.url).toString() : undefined
105
+
106
+ // Deep-merged Twitter
107
+ const defaultTwitter: TwitterMeta = {
108
+ card: twitterCard || config?.twitter?.card || (absoluteResolvedOgImage ? 'summary_large_image' : 'summary'),
109
+ ...(twitterHandle && { site: twitterHandle, creator: twitterHandle }),
110
+ title: resolvedOpenGraph.title || title || siteName,
111
+ ...(safeDescription && { description: safeDescription }),
112
+ ...(absoluteResolvedOgImage && { images: absoluteResolvedOgImage }),
113
+ ...config?.twitter,
114
+ }
115
+ const resolvedTwitter = deepMerge(defaultTwitter, twitter || {})
116
+ if (twitterCard) {
117
+ resolvedTwitter.card = twitterCard
118
+ }
119
+ if (!resolvedTwitter.card) {
120
+ resolvedTwitter.card = absoluteResolvedOgImage ? 'summary_large_image' : 'summary'
121
+ }
75
122
  ---
76
123
 
77
124
  <!-- Basic Metadata -->
@@ -103,24 +150,24 @@ const robotsContent = buildRobotsMetaContent({
103
150
  ))}
104
151
 
105
152
  <!-- Open Graph / Facebook -->
106
- <meta property="og:type" content={ogType} />
107
- <meta property="og:locale" content={ogLocale} />
108
- <meta property="og:url" content={Astro.url.href} />
109
- <meta property="og:title" content={title || siteName} />
110
- {safeDescription && <meta property="og:description" content={safeDescription} />}
111
- {absoluteOgImage && <meta property="og:image" content={absoluteOgImage} />}
112
- {finalOgImage && <meta property="og:image:alt" content={finalOgImage.alt} />}
113
- <meta property="og:site_name" content={siteName} />
153
+ <meta property="og:type" content={resolvedOpenGraph.type || 'website'} />
154
+ <meta property="og:locale" content={resolvedOpenGraph.locale || 'en_US'} />
155
+ <meta property="og:url" content={resolvedOpenGraph.url || Astro.url.href} />
156
+ <meta property="og:title" content={resolvedOpenGraph.title || title || siteName} />
157
+ {resolvedOpenGraph.description && <meta property="og:description" content={resolvedOpenGraph.description} />}
158
+ {absoluteResolvedOgImage && <meta property="og:image" content={absoluteResolvedOgImage} />}
159
+ {ogImgAlt && <meta property="og:image:alt" content={ogImgAlt} />}
160
+ <meta property="og:site_name" content={resolvedOpenGraph.siteName || siteName} />
114
161
 
115
162
  <!-- Twitter -->
116
- <meta property="twitter:card" content="summary_large_image" />
163
+ <meta property="twitter:card" content={resolvedTwitter.card} />
117
164
  <meta property="twitter:url" content={Astro.url.href} />
118
- <meta property="twitter:title" content={title || siteName} />
119
- {safeDescription && <meta property="twitter:description" content={safeDescription} />}
120
- {absoluteOgImage && <meta property="twitter:image" content={absoluteOgImage} />}
121
- {finalOgImage && <meta property="twitter:image:alt" content={finalOgImage.alt} />}
122
- {twitterHandle && <meta property="twitter:site" content={twitterHandle} />}
123
- {twitterHandle && <meta property="twitter:creator" content={twitterHandle} />}
165
+ <meta property="twitter:title" content={resolvedTwitter.title || title || siteName} />
166
+ {resolvedTwitter.description && <meta property="twitter:description" content={resolvedTwitter.description} />}
167
+ {absoluteResolvedOgImage && <meta property="twitter:image" content={absoluteResolvedOgImage} />}
168
+ {ogImgAlt && <meta property="twitter:image:alt" content={ogImgAlt} />}
169
+ {resolvedTwitter.site && <meta property="twitter:site" content={resolvedTwitter.site} />}
170
+ {resolvedTwitter.creator && <meta property="twitter:creator" content={resolvedTwitter.creator} />}
124
171
 
125
172
  <!-- Icons & Manifest -->
126
173
  <link rel="icon" type={faviconType} href={favicon} />
package/src/env.d.ts CHANGED
@@ -1,3 +1,33 @@
1
- declare module "*.astro" {
2
- export default {} as import("astro/runtime/server").AstroComponentFactory
3
- }
1
+ declare module "*.astro" {
2
+ export default {} as import("astro/runtime/server").AstroComponentFactory
3
+ }
4
+
5
+ declare module "virtual:rimelight-seo-config" {
6
+ const config: import("./integration.js").RimelightSeoOptions
7
+ export default config
8
+ }
9
+
10
+ declare module "virtual:rimelight-seo/options" {
11
+ const config: import("./integration.js").RimelightSeoOptions
12
+ export default config
13
+ }
14
+
15
+ declare module "virtual:rimelight-seo/config" {
16
+ export const SEO_LOCALES: import("./types.js").LocaleConfig | undefined
17
+ export const PRIVATE_PATH_PREFIXES: string[] | undefined
18
+ export const seoEntries: (() => Promise<import("./types.js").SeoEntry[]>) | undefined
19
+ export const rssEntries: (() => Promise<import("./types.js").RssItem[]>) | undefined
20
+ }
21
+
22
+ declare module "virtual:rimelight-seo/site-config" {
23
+ export const siteConfig: import("./types.js").SiteConfig | Record<string, any>
24
+ }
25
+
26
+ declare module "virtual:rimelight-seo/db" {
27
+ export const db: any
28
+ export const pages: any
29
+ }
30
+
31
+ declare module "virtual:rimelight-seo/corpus" {
32
+ export const corpusPages: any[]
33
+ }
package/src/index.ts CHANGED
@@ -2,17 +2,26 @@ export type {
2
2
  ChangeFreq,
3
3
  LocaleConfig,
4
4
  RobotsOptions,
5
+ RobotsRouteOptions,
5
6
  SeoEntry,
6
7
  SitemapAlternate,
7
8
  SitemapUrl,
9
+ SitemapOptions,
8
10
  UserAgentRule,
9
11
  SiteConfig,
10
12
  OGImage,
11
13
  RSSFeed,
14
+ RssItem,
15
+ RssFeedOptions,
16
+ RssOptions,
12
17
  HeadProps,
18
+ OpenGraphMeta,
19
+ TwitterMeta,
13
20
  LlmsPage,
14
21
  LlmsTxtOptions,
15
22
  LlmsFullTxtOptions,
23
+ LlmsRouteOptions,
24
+ LlmsFullRouteOptions,
16
25
  ArticleSchemaOptions,
17
26
  BreadcrumbItem
18
27
  } from "./types.js"
@@ -26,13 +35,22 @@ export {
26
35
 
27
36
  export { buildRobotsTxt } from "./robots.js"
28
37
 
38
+ export { buildRssXml, escapeXml } from "./rss.js"
39
+
29
40
  export {
30
41
  buildCanonicalUrl,
31
42
  buildPageTitle,
32
43
  buildSafeDescription,
33
44
  buildRobotsMetaContent,
34
- buildWebSiteSchema
45
+ buildWebSiteSchema,
46
+ deepMerge
35
47
  } from "./meta.js"
36
48
 
37
49
  export { buildLlmsTxt, buildLlmsFullTxt } from "./llms.js"
38
50
  export { buildArticleSchema, buildBreadcrumbSchema } from "./schema.js"
51
+ export type { OgFontConfig, OgLayoutOptions, OgRenderConfig } from "./og.js"
52
+
53
+ import { rimelightSeo } from "./integration.js"
54
+ export type { RimelightSeoOptions } from "./integration.js"
55
+ export { rimelightSeo }
56
+ export default rimelightSeo
@@ -0,0 +1,172 @@
1
+ import type { AstroIntegration } from "astro"
2
+ import path from "node:path"
3
+ import fs from "node:fs"
4
+ import { fileURLToPath } from "node:url"
5
+ import type { RimelightSeoOptions } from "./types.js"
6
+
7
+ export { type RimelightSeoOptions } from "./types.js"
8
+
9
+ export function rimelightSeo(options: RimelightSeoOptions = {}): AstroIntegration {
10
+ return {
11
+ name: "@rimelight/seo",
12
+ hooks: {
13
+ "astro:config:setup": ({ injectRoute, updateConfig, config }) => {
14
+ const rootDir = fileURLToPath(config.root)
15
+
16
+ // Resolve site files
17
+ const findFile = (candidates: string[]): string | null => {
18
+ for (const c of candidates) {
19
+ const resolved = path.resolve(rootDir, c)
20
+ if (fs.existsSync(resolved)) return resolved
21
+ }
22
+ return null
23
+ }
24
+
25
+ const seoConfigPath = findFile([
26
+ "src/config/seo.config.ts",
27
+ "src/config/seo.config.js",
28
+ "src/config/seo.config.mjs"
29
+ ])
30
+
31
+ const siteConfigPath = findFile([
32
+ "src/config/site.config.ts",
33
+ "src/config/site.config.js",
34
+ "src/config/site.config.mjs"
35
+ ])
36
+
37
+ const dbPath = findFile(["src/db/index.ts", "src/db/index.js", "src/db/index.mjs"])
38
+
39
+ const corpusPath = findFile([
40
+ "src/config/llms.corpus.ts",
41
+ "src/config/llms.corpus.js",
42
+ "src/config/llms.corpus.mjs"
43
+ ])
44
+
45
+ // Inject routes
46
+ if (options.robots !== false) {
47
+ const pattern =
48
+ typeof options.robots === "object" && options.robots.path
49
+ ? options.robots.path
50
+ : "/robots.txt"
51
+ injectRoute({
52
+ pattern,
53
+ entrypoint: "@rimelight/seo/routes/robots.txt.ts"
54
+ })
55
+ }
56
+
57
+ if (options.sitemap !== false) {
58
+ const pattern =
59
+ typeof options.sitemap === "object" && options.sitemap.path
60
+ ? options.sitemap.path
61
+ : "/sitemap.xml"
62
+ injectRoute({
63
+ pattern,
64
+ entrypoint: "@rimelight/seo/routes/sitemap.xml.ts"
65
+ })
66
+ }
67
+
68
+ if (options.rss !== false) {
69
+ const pattern =
70
+ typeof options.rss === "object" && options.rss.path ? options.rss.path : "/rss.xml"
71
+ injectRoute({
72
+ pattern,
73
+ entrypoint: "@rimelight/seo/routes/rss.xml.ts"
74
+ })
75
+ }
76
+
77
+ if (options.llms !== false) {
78
+ const pattern =
79
+ typeof options.llms === "object" && options.llms.path ? options.llms.path : "/llms.txt"
80
+ injectRoute({
81
+ pattern,
82
+ entrypoint: "@rimelight/seo/routes/llms.txt.ts"
83
+ })
84
+ }
85
+
86
+ if (options.llmsFull !== false) {
87
+ const pattern =
88
+ typeof options.llmsFull === "object" && options.llmsFull.path
89
+ ? options.llmsFull.path
90
+ : "/llms-full.txt"
91
+ injectRoute({
92
+ pattern,
93
+ entrypoint: "@rimelight/seo/routes/llms-full.txt.ts"
94
+ })
95
+ }
96
+
97
+ updateConfig({
98
+ vite: {
99
+ plugins: [
100
+ {
101
+ name: "vite-plugin-rimelight-seo",
102
+ resolveId(id) {
103
+ if (
104
+ id === "virtual:rimelight-seo-config" ||
105
+ id === "virtual:rimelight-seo/options"
106
+ ) {
107
+ return "\0virtual:rimelight-seo/options"
108
+ }
109
+ if (id === "virtual:rimelight-seo/config") {
110
+ return "\0virtual:rimelight-seo/config"
111
+ }
112
+ if (id === "virtual:rimelight-seo/site-config") {
113
+ return "\0virtual:rimelight-seo/site-config"
114
+ }
115
+ if (id === "virtual:rimelight-seo/db") {
116
+ return "\0virtual:rimelight-seo/db"
117
+ }
118
+ if (id === "virtual:rimelight-seo/corpus") {
119
+ return "\0virtual:rimelight-seo/corpus"
120
+ }
121
+ return null
122
+ },
123
+ load(id) {
124
+ if (id === "\0virtual:rimelight-seo/options") {
125
+ return `export default ${JSON.stringify(options)};`
126
+ }
127
+ if (id === "\0virtual:rimelight-seo/config") {
128
+ if (seoConfigPath) {
129
+ return `import * as customConfig from ${JSON.stringify(seoConfigPath)};
130
+ export const SEO_LOCALES = customConfig.SEO_LOCALES;
131
+ export const PRIVATE_PATH_PREFIXES = customConfig.PRIVATE_PATH_PREFIXES || [];
132
+ export const seoEntries = customConfig.seoEntries;
133
+ export const rssEntries = customConfig.rssEntries;
134
+ export default customConfig;`
135
+ }
136
+ return `export const SEO_LOCALES = undefined;
137
+ export const PRIVATE_PATH_PREFIXES = [];
138
+ export const seoEntries = async () => [];
139
+ export const rssEntries = undefined;`
140
+ }
141
+ if (id === "\0virtual:rimelight-seo/site-config") {
142
+ if (siteConfigPath) {
143
+ return `export * from ${JSON.stringify(siteConfigPath)};`
144
+ }
145
+ return `export const siteConfig = { name: "Site", description: "", url: "" };`
146
+ }
147
+ if (id === "\0virtual:rimelight-seo/db") {
148
+ if (dbPath) {
149
+ return `export * from ${JSON.stringify(dbPath)};`
150
+ }
151
+ return `export const db = null;
152
+ export const pages = null;`
153
+ }
154
+ if (id === "\0virtual:rimelight-seo/corpus") {
155
+ if (corpusPath) {
156
+ return `export * from ${JSON.stringify(corpusPath)};`
157
+ }
158
+ return `export const corpusPages = [];`
159
+ }
160
+ return null
161
+ }
162
+ }
163
+ ]
164
+ }
165
+ })
166
+ }
167
+ }
168
+ }
169
+ }
170
+
171
+ export default rimelightSeo
172
+
package/src/meta.test.ts CHANGED
@@ -4,7 +4,8 @@ import {
4
4
  buildPageTitle,
5
5
  buildSafeDescription,
6
6
  buildRobotsMetaContent,
7
- buildWebSiteSchema
7
+ buildWebSiteSchema,
8
+ deepMerge
8
9
  } from "./meta.js"
9
10
 
10
11
  const BASE = "https://example.com"
@@ -49,6 +50,29 @@ describe("buildPageTitle", () => {
49
50
  "404 - Not Found | My Site"
50
51
  )
51
52
  })
53
+
54
+ it("bypasses title template when absolute is true", () => {
55
+ expect(buildPageTitle({ ...opts, title: "Landing Page", absolute: true })).toBe("Landing Page")
56
+ })
57
+ })
58
+
59
+ describe("deepMerge", () => {
60
+ it("deep merges nested objects", () => {
61
+ const base = {
62
+ openGraph: { type: "website", siteName: "Base", locale: "en_US" },
63
+ twitter: { card: "summary", site: "@base" }
64
+ }
65
+ const override = {
66
+ openGraph: { siteName: "Override" },
67
+ twitter: { card: "summary_large_image" }
68
+ }
69
+ const result = deepMerge(base, override)
70
+ expect(result.openGraph.type).toBe("website")
71
+ expect(result.openGraph.siteName).toBe("Override")
72
+ expect(result.openGraph.locale).toBe("en_US")
73
+ expect(result.twitter.card).toBe("summary_large_image")
74
+ expect(result.twitter.site).toBe("@base")
75
+ })
52
76
  })
53
77
 
54
78
  describe("buildSafeDescription", () => {
package/src/meta.ts CHANGED
@@ -12,19 +12,49 @@ export function buildCanonicalUrl(url: string | URL, base: string | URL): string
12
12
  return path.includes("?") ? path.replace(/\/?$/, "") : path.replace(/\/?$/, "/")
13
13
  }
14
14
 
15
+ /**
16
+ * Deep merge two metadata or configuration objects.
17
+ */
18
+ export function deepMerge<T extends Record<string, any>>(
19
+ base: T,
20
+ override?: Record<string, any>
21
+ ): T {
22
+ if (!override) return { ...base }
23
+ const result: Record<string, any> = { ...base }
24
+ for (const key of Object.keys(override)) {
25
+ const baseVal = base[key]
26
+ const overrideVal = override[key]
27
+ if (
28
+ baseVal &&
29
+ overrideVal &&
30
+ typeof baseVal === "object" &&
31
+ typeof overrideVal === "object" &&
32
+ !Array.isArray(baseVal) &&
33
+ !Array.isArray(overrideVal)
34
+ ) {
35
+ result[key] = deepMerge(baseVal, overrideVal)
36
+ } else if (overrideVal !== undefined) {
37
+ result[key] = overrideVal
38
+ }
39
+ }
40
+ return result as T
41
+ }
42
+
15
43
  /**
16
44
  * Build a page title using a site's title template. Falls back to siteName for untitled pages; uses
17
- * a 404 label for error pages.
45
+ * a 404 label for error pages. Supports absolute title bypass.
18
46
  */
19
47
  export function buildPageTitle(opts: {
20
48
  title?: string
21
49
  siteName: string
22
50
  titleTemplate: string
23
51
  is404?: boolean
52
+ absolute?: boolean
24
53
  }): string {
25
- const { title, siteName, titleTemplate, is404 } = opts
54
+ const { title, siteName, titleTemplate, is404, absolute } = opts
26
55
  if (is404) return `404 - Not Found | ${siteName}`
27
56
  if (!title) return siteName
57
+ if (absolute) return title
28
58
  return titleTemplate.replace("%s", title)
29
59
  }
30
60