@rimelight/seo 0.0.8 → 0.0.9

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rimelight Entertainment
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/index.d.mts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { generateIco, generateMaskablePng, generateOgCard, generatePng } from "./assets.mjs";
2
2
  import { i as buildBreadcrumbSchema, n as BreadcrumbItem, r as buildArticleSchema, t as ArticleSchemaOptions } from "./schema-C8v69blQ.mjs";
3
3
  import { a as buildLlmsTxt, i as buildLlmsFullTxt, n as LlmsPage, r as LlmsTxtOptions, t as LlmsFullTxtOptions } from "./llms-BVLxzjr-.mjs";
4
- import { ApiCatalog, ApiCatalogItem, ApiCatalogOptions, ApiCatalogRouteOptions, AssetRouteOptions, ChangeFreq, HeadProps, LlmsFullRouteOptions, LlmsRouteOptions, LocaleConfig, ManifestRouteOptions, OGImage, OpenGraphMeta, RSSFeed, RimelightSeoOptions, RobotsOptions, RobotsRouteOptions, RssCollectionConfig, RssFeedOptions, RssItem, RssOptions, SecurityRouteOptions, SecurityTxtOptions, SeoEntry, SiteConfig, SitemapAlternate, SitemapOptions, SitemapUrl, TwitterMeta, UserAgentRule, WebManifest, WebManifestIcon, WebManifestOptions, WebManifestShortcut } from "./types.mjs";
4
+ import { ApiCatalog, ApiCatalogItem, ApiCatalogOptions, ApiCatalogRouteOptions, AssetRouteOptions, ChangeFreq, HeadProps, LlmsFullRouteOptions, LlmsRouteOptions, LocaleConfig, ManifestRouteOptions, OGImage, OgRouteOptions, OpenGraphMeta, RSSFeed, RimelightSeoOptions, RobotsOptions, RobotsRouteOptions, RssCollectionConfig, RssFeedOptions, RssItem, RssOptions, SecurityRouteOptions, SecurityTxtOptions, SeoEntry, SiteConfig, SitemapAlternate, SitemapOptions, SitemapUrl, TwitterMeta, UserAgentRule, WebManifest, WebManifestIcon, WebManifestOptions, WebManifestShortcut } from "./types.mjs";
5
5
  import { buildApiCatalog } from "./catalog.mjs";
6
6
  import { buildSitemapIndexXml, buildSitemapUrls, buildSitemapXml, chunkSitemapUrls } from "./sitemap.mjs";
7
7
  import { buildRobotsTxt } from "./robots.mjs";
@@ -11,4 +11,4 @@ import { buildSecurityTxt } from "./security.mjs";
11
11
  import { buildWebManifest } from "./manifest.mjs";
12
12
  import { OgFontConfig, OgLayoutOptions, OgRenderConfig } from "./og.mjs";
13
13
  import rimelightSeo from "./integration.mjs";
14
- export { type ApiCatalog, type ApiCatalogItem, type ApiCatalogOptions, type ApiCatalogRouteOptions, type ArticleSchemaOptions, type AssetRouteOptions, type BreadcrumbItem, type ChangeFreq, type HeadProps, type LlmsFullRouteOptions, type LlmsFullTxtOptions, type LlmsPage, type LlmsRouteOptions, type LlmsTxtOptions, type LocaleConfig, type ManifestRouteOptions, type OGImage, type OgFontConfig, type OgLayoutOptions, type OgRenderConfig, type OpenGraphMeta, type RSSFeed, type RimelightSeoOptions, type RobotsOptions, type RobotsRouteOptions, type RssCollectionConfig, type RssFeedOptions, type RssItem, type RssOptions, type SecurityRouteOptions, type SecurityTxtOptions, type SeoEntry, type SiteConfig, type SitemapAlternate, type SitemapOptions, type SitemapUrl, type TwitterMeta, type UserAgentRule, type WebManifest, type WebManifestIcon, type WebManifestOptions, type WebManifestShortcut, buildApiCatalog, buildArticleSchema, buildBreadcrumbSchema, buildCanonicalUrl, buildLlmsFullTxt, buildLlmsTxt, buildPageTitle, buildRobotsMetaContent, buildRobotsTxt, buildRssXml, buildSafeDescription, buildSecurityTxt, buildSitemapIndexXml, buildSitemapUrls, buildSitemapXml, buildWebManifest, buildWebSiteSchema, chunkSitemapUrls, deepMerge, rimelightSeo as default, rimelightSeo, escapeXml, generateIco, generateMaskablePng, generateOgCard, generatePng };
14
+ export { type ApiCatalog, type ApiCatalogItem, type ApiCatalogOptions, type ApiCatalogRouteOptions, type ArticleSchemaOptions, type AssetRouteOptions, type BreadcrumbItem, type ChangeFreq, type HeadProps, type LlmsFullRouteOptions, type LlmsFullTxtOptions, type LlmsPage, type LlmsRouteOptions, type LlmsTxtOptions, type LocaleConfig, type ManifestRouteOptions, type OGImage, type OgFontConfig, type OgLayoutOptions, type OgRenderConfig, type OgRouteOptions, type OpenGraphMeta, type RSSFeed, type RimelightSeoOptions, type RobotsOptions, type RobotsRouteOptions, type RssCollectionConfig, type RssFeedOptions, type RssItem, type RssOptions, type SecurityRouteOptions, type SecurityTxtOptions, type SeoEntry, type SiteConfig, type SitemapAlternate, type SitemapOptions, type SitemapUrl, type TwitterMeta, type UserAgentRule, type WebManifest, type WebManifestIcon, type WebManifestOptions, type WebManifestShortcut, buildApiCatalog, buildArticleSchema, buildBreadcrumbSchema, buildCanonicalUrl, buildLlmsFullTxt, buildLlmsTxt, buildPageTitle, buildRobotsMetaContent, buildRobotsTxt, buildRssXml, buildSafeDescription, buildSecurityTxt, buildSitemapIndexXml, buildSitemapUrls, buildSitemapXml, buildWebManifest, buildWebSiteSchema, chunkSitemapUrls, deepMerge, rimelightSeo as default, rimelightSeo, escapeXml, generateIco, generateMaskablePng, generateOgCard, generatePng };
@@ -15,21 +15,35 @@ function rimelightSeo(options = {}) {
15
15
  }
16
16
  return null;
17
17
  };
18
- const seoConfigPath = findFile([
19
- "src/config/seo.config.ts",
20
- "src/config/seo.config.js",
21
- "src/config/seo.config.mjs"
22
- ]);
23
- const hasConfigExport = (name) => {
24
- if (!seoConfigPath) return false;
25
- const content = fs.readFileSync(seoConfigPath, "utf8");
26
- return new RegExp(`export\\s+(?:const|let|var|async\\s+function|function|class)\\s+${name}\\b`).test(content) || new RegExp(`export\\s*\\{[^}]*\\b${name}\\b}`).test(content);
18
+ const siteOpt = options.site || {};
19
+ const normalizedSiteConfig = {
20
+ id: siteOpt.id || options.id || "",
21
+ name: siteOpt.name || options.name || "",
22
+ description: siteOpt.description || options.description || "",
23
+ url: siteOpt.url || options.url || "",
24
+ ogImage: siteOpt.ogImage || options.ogImage || void 0,
25
+ author: siteOpt.author || options.author || siteOpt.name || options.name || "",
26
+ email: siteOpt.email || options.email || "",
27
+ branding: {
28
+ logo: { alt: siteOpt.branding?.logo?.alt || options.branding?.logo?.alt || siteOpt.name || options.name || "" },
29
+ colors: {
30
+ themeColor: siteOpt.branding?.colors?.themeColor || options.branding?.colors?.themeColor || "#ffffff",
31
+ backgroundColor: siteOpt.branding?.colors?.backgroundColor || options.branding?.colors?.backgroundColor || "#ffffff"
32
+ },
33
+ ...siteOpt.branding,
34
+ ...options.branding
35
+ },
36
+ seo: {
37
+ titleTemplate: siteOpt.seo?.titleTemplate || options.titleTemplate || (options.name ? `%s | ${options.name}` : "%s"),
38
+ maxDescriptionLength: siteOpt.seo?.maxDescriptionLength || options.maxDescriptionLength || 160,
39
+ authorHandle: siteOpt.seo?.authorHandle || options.authorHandle || void 0,
40
+ ...siteOpt.seo
41
+ },
42
+ ...siteOpt.openGraph || options.openGraph ? { openGraph: siteOpt.openGraph || options.openGraph } : {},
43
+ ...siteOpt.twitter || options.twitter ? { twitter: siteOpt.twitter || options.twitter } : {}
27
44
  };
28
- const siteConfigPath = findFile([
29
- "src/config/site.config.ts",
30
- "src/config/site.config.js",
31
- "src/config/site.config.mjs"
32
- ]);
45
+ const seoLocales = options.locales;
46
+ const privatePathPrefixes = options.privatePathPrefixes || [];
33
47
  const dbPath = findFile([
34
48
  "src/db/index.ts",
35
49
  "src/db/index.js",
@@ -63,10 +77,16 @@ function rimelightSeo(options = {}) {
63
77
  pattern: typeof options.robots === "object" && options.robots.path ? options.robots.path : "/robots.txt",
64
78
  entrypoint: "@rimelight/seo/routes/robots.txt.ts"
65
79
  });
66
- if (options.sitemap !== false) injectRoute({
67
- pattern: typeof options.sitemap === "object" && options.sitemap.path ? options.sitemap.path : "/sitemap.xml",
68
- entrypoint: "@rimelight/seo/routes/sitemap.xml.ts"
69
- });
80
+ if (options.sitemap !== false) {
81
+ injectRoute({
82
+ pattern: typeof options.sitemap === "object" && options.sitemap.path ? options.sitemap.path : "/sitemap.xml",
83
+ entrypoint: "@rimelight/seo/routes/sitemap.xml.ts"
84
+ });
85
+ injectRoute({
86
+ pattern: "/sitemap-index.xml",
87
+ entrypoint: "@rimelight/seo/routes/sitemap-index.xml.ts"
88
+ });
89
+ }
70
90
  if (options.rss !== false) injectRoute({
71
91
  pattern: typeof options.rss === "object" && options.rss.path ? options.rss.path : "/rss.xml",
72
92
  entrypoint: "@rimelight/seo/routes/rss.xml.ts"
@@ -98,6 +118,10 @@ function rimelightSeo(options = {}) {
98
118
  pattern: typeof options.manifest === "object" && options.manifest.path ? options.manifest.path : "/site.webmanifest",
99
119
  entrypoint: "@rimelight/seo/routes/site.webmanifest.ts"
100
120
  });
121
+ if (options.og !== false) injectRoute({
122
+ pattern: typeof options.og === "object" && options.og.path ? options.og.path : "/open-graph/[...route].png",
123
+ entrypoint: "@rimelight/seo/routes/open-graph.[...route].png.ts"
124
+ });
101
125
  updateConfig({ vite: { plugins: [{
102
126
  name: "vite-plugin-rimelight-seo-assets",
103
127
  configureServer(server) {
@@ -245,6 +269,7 @@ function rimelightSeo(options = {}) {
245
269
  name: "vite-plugin-rimelight-seo",
246
270
  resolveId(id) {
247
271
  if (id === "virtual:rimelight-seo-config" || id === "virtual:rimelight-seo/options") return "\0virtual:rimelight-seo/options";
272
+ if (id === "virtual:rimelight/seo") return "\0virtual:rimelight/seo";
248
273
  if (id === "virtual:rimelight-seo/config") return "\0virtual:rimelight-seo/config";
249
274
  if (id === "virtual:rimelight-seo/site-config") return "\0virtual:rimelight-seo/site-config";
250
275
  if (id === "virtual:rimelight-seo/db") return "\0virtual:rimelight-seo/db";
@@ -253,26 +278,21 @@ function rimelightSeo(options = {}) {
253
278
  },
254
279
  load(id) {
255
280
  if (id === "\0virtual:rimelight-seo/options") return `export default ${JSON.stringify(options)};`;
256
- if (id === "\0virtual:rimelight-seo/config") {
257
- if (seoConfigPath) {
258
- const seoEntriesExport = hasConfigExport("seoEntries");
259
- const rssEntriesExport = hasConfigExport("rssEntries");
260
- return `import * as customConfig from ${JSON.stringify(seoConfigPath)};
261
- export const SEO_LOCALES = customConfig.SEO_LOCALES;
262
- export const PRIVATE_PATH_PREFIXES = customConfig.PRIVATE_PATH_PREFIXES || [];
263
- export const seoEntries = ${seoEntriesExport ? "customConfig.seoEntries" : "undefined"};
264
- export const rssEntries = ${rssEntriesExport ? "customConfig.rssEntries" : "undefined"};
265
- export default customConfig;`;
266
- }
267
- return `export const SEO_LOCALES = undefined;
268
- export const PRIVATE_PATH_PREFIXES = [];
281
+ if (id === "\0virtual:rimelight/seo") return `export const siteConfig = ${JSON.stringify(normalizedSiteConfig)};
282
+ export const SEO_LOCALES = ${JSON.stringify(seoLocales)};
283
+ export const PRIVATE_PATH_PREFIXES = ${JSON.stringify(privatePathPrefixes)};
284
+ export const seoConfig = {
285
+ SEO_LOCALES: ${JSON.stringify(seoLocales)},
286
+ PRIVATE_PATH_PREFIXES: ${JSON.stringify(privatePathPrefixes)}
287
+ };
288
+ export default siteConfig;`;
289
+ if (id === "\0virtual:rimelight-seo/config") return `export const SEO_LOCALES = ${JSON.stringify(seoLocales)};
290
+ export const PRIVATE_PATH_PREFIXES = ${JSON.stringify(privatePathPrefixes)};
269
291
  export const seoEntries = async () => [];
270
- export const rssEntries = undefined;`;
271
- }
272
- if (id === "\0virtual:rimelight-seo/site-config") {
273
- if (siteConfigPath) return `export * from ${JSON.stringify(siteConfigPath)};`;
274
- return `export const siteConfig = { name: "Site", description: "", url: "" };`;
275
- }
292
+ export const rssEntries = undefined;
293
+ export default { SEO_LOCALES: ${JSON.stringify(seoLocales)}, PRIVATE_PATH_PREFIXES: ${JSON.stringify(privatePathPrefixes)} };`;
294
+ if (id === "\0virtual:rimelight-seo/site-config") return `export const siteConfig = ${JSON.stringify(normalizedSiteConfig)};
295
+ export default siteConfig;`;
276
296
  if (id === "\0virtual:rimelight-seo/db") {
277
297
  if (dbPath) return `export * from ${JSON.stringify(dbPath)};`;
278
298
  return `export const db = null;
@@ -0,0 +1,5 @@
1
+ import { APIRoute } from "astro";
2
+ //#region src/routes/open-graph.[...route].png.d.ts
3
+ export declare const prerender = false;
4
+ export declare const GET: APIRoute;
5
+ //#endregion
@@ -0,0 +1,57 @@
1
+ import { renderDefaultOg } from "../og.mjs";
2
+ import seoOptions from "virtual:rimelight-seo/options";
3
+ import * as siteConfigMod from "virtual:rimelight-seo/site-config";
4
+ import * as dbMod from "virtual:rimelight-seo/db";
5
+ //#region src/routes/open-graph.[...route].png.ts
6
+ const prerender = false;
7
+ function getLocalizedText(val, locale = "en") {
8
+ if (typeof val === "object" && val !== null) {
9
+ const record = val;
10
+ return record[locale] || Object.values(record)[0] || "";
11
+ }
12
+ if (typeof val === "string") return val;
13
+ if (typeof val === "number" || typeof val === "boolean") return String(val);
14
+ return "";
15
+ }
16
+ const GET = async ({ request }) => {
17
+ const url = new URL(request.url);
18
+ const routeParam = url.pathname.replace(/^\/open-graph\//, "").replace(/\.png$/, "");
19
+ const siteConfig = siteConfigMod.siteConfig || {};
20
+ const ogOpts = typeof seoOptions?.og === "object" ? seoOptions.og : {};
21
+ const brand = ogOpts.brand || seoOptions?.name || siteConfig.name || url.hostname;
22
+ const defaultBg = ogOpts.defaultBackground ?? "dark";
23
+ const docsBg = ogOpts.docsBackground ?? "gradient";
24
+ let title = url.searchParams.get("title") || "";
25
+ let description = url.searchParams.get("description") || "";
26
+ let date = url.searchParams.get("pubDate") || "";
27
+ const badge = url.searchParams.get("type") || (routeParam.includes("blog") ? "Blog Post" : routeParam.includes("legal") ? "Legal" : "");
28
+ if (!title && routeParam && routeParam !== "page" && routeParam !== "default" && dbMod.db) try {
29
+ const { pages: pagesTable } = await import("#db/schema").catch(() => ({ pages: null }));
30
+ const { eq, and, isNull } = await import("drizzle-orm");
31
+ if (pagesTable) {
32
+ const slug = routeParam.split("/").pop() ?? "";
33
+ const page = await dbMod.db.select({
34
+ title: pagesTable.title,
35
+ description: pagesTable.description,
36
+ postedAt: pagesTable.postedAt
37
+ }).from(pagesTable).where(and(eq(pagesTable.slug, slug), isNull(pagesTable.deletedAt))).limit(1).then((r) => r[0]).catch(() => void 0);
38
+ if (page) {
39
+ title = getLocalizedText(page.title);
40
+ description = description || getLocalizedText(page.description);
41
+ if (page.postedAt && !date) date = new Date(page.postedAt).toLocaleDateString();
42
+ }
43
+ }
44
+ } catch {}
45
+ const isDocs = url.searchParams.get("isDocs") === "true" || routeParam.includes("docs");
46
+ return renderDefaultOg({
47
+ title: title || brand,
48
+ description,
49
+ brand,
50
+ badge,
51
+ date,
52
+ background: isDocs ? docsBg : defaultBg,
53
+ preview: url.searchParams.get("preview") === "1"
54
+ });
55
+ };
56
+ //#endregion
57
+ export { GET, prerender };
@@ -0,0 +1,4 @@
1
+ import { APIRoute } from "astro";
2
+ //#region src/routes/sitemap-index.xml.d.ts
3
+ export declare const GET: APIRoute;
4
+ //#endregion
@@ -0,0 +1,16 @@
1
+ import { buildSitemapIndexXml } from "../sitemap.mjs";
2
+ import seoOptions from "virtual:rimelight-seo/options";
3
+ import * as siteConfigMod from "virtual:rimelight-seo/site-config";
4
+ //#region src/routes/sitemap-index.xml.ts
5
+ const GET = ({ site, url }) => {
6
+ const siteUrl = site ? site.toString() : siteConfigMod.siteConfig?.url || url.origin;
7
+ const sitemapOpts = typeof seoOptions?.sitemap === "object" ? seoOptions.sitemap : {};
8
+ const sitemapPattern = typeof sitemapOpts.path === "string" ? sitemapOpts.path : "/sitemap.xml";
9
+ const xml = buildSitemapIndexXml([new URL(sitemapPattern, siteUrl).toString()]);
10
+ return new Response(xml, { headers: {
11
+ "Content-Type": "application/xml; charset=utf-8",
12
+ "Cache-Control": "public, max-age=3600, stale-while-revalidate=86400"
13
+ } });
14
+ };
15
+ //#endregion
16
+ export { GET };
package/dist/types.d.mts CHANGED
@@ -493,6 +493,47 @@ export interface AssetRouteOptions {
493
493
  ogImage?: boolean;
494
494
  }
495
495
  export interface RimelightSeoOptions {
496
+ /**
497
+ * Site identifier / domain (e.g. "rimelight.com")
498
+ */
499
+ id?: string;
500
+ /**
501
+ * Primary site name (e.g. "Rimelight")
502
+ */
503
+ name?: string;
504
+ /**
505
+ * Default site description
506
+ */
507
+ description?: string;
508
+ /**
509
+ * Canonical origin URL (e.g. "https://rimelight.com")
510
+ */
511
+ url?: string;
512
+ ogImage?: string;
513
+ author?: string;
514
+ email?: string;
515
+ branding?: Partial<SiteConfig["branding"]>;
516
+ titleTemplate?: string;
517
+ maxDescriptionLength?: number;
518
+ authorHandle?: string;
519
+ openGraph?: OpenGraphMeta;
520
+ twitter?: TwitterMeta;
521
+ /**
522
+ * Full or partial SiteConfig object
523
+ */
524
+ site?: Partial<SiteConfig>;
525
+ /**
526
+ * Locale mappings for hreflang / sitemaps
527
+ */
528
+ locales?: LocaleConfig | Record<string, string>;
529
+ /**
530
+ * Path prefixes to exclude from sitemap and indexing
531
+ */
532
+ privatePathPrefixes?: string[];
533
+ /**
534
+ * Custom SEO entry generator function
535
+ */
536
+ entries?: () => Promise<SeoEntry[]> | SeoEntry[];
496
537
  /**
497
538
  * Sitemap configuration or false to disable automatic /sitemap.xml (default: true)
498
539
  */
@@ -532,6 +573,40 @@ export interface RimelightSeoOptions {
532
573
  * icons) from a single master icon (default: true)
533
574
  */
534
575
  assets?: boolean | AssetRouteOptions;
576
+ /**
577
+ * Open Graph image route configuration, or false to disable automatic /open-graph/[...route].png
578
+ * (default: true). When enabled, the route reads brand/background from seo.name and injects a
579
+ * handler that supports query-param overrides and optional DB slug lookup.
580
+ */
581
+ og?: boolean | OgRouteOptions;
582
+ }
583
+ /**
584
+ * Configuration for the auto-injected /open-graph/[...route].png route.
585
+ */
586
+ export interface OgRouteOptions {
587
+ /**
588
+ * Route pattern for the OG image handler.
589
+ *
590
+ * @default "/open-graph/[...route].png"
591
+ */
592
+ path?: string;
593
+ /**
594
+ * Brand name shown top-left of every OG image. Defaults to seo.name / site.name from integration
595
+ * config.
596
+ */
597
+ brand?: string;
598
+ /**
599
+ * Background style used for standard pages.
600
+ *
601
+ * @default "dark"
602
+ */
603
+ defaultBackground?: "dark" | "gradient" | (string & {});
604
+ /**
605
+ * Background style used when ?isDocs=true or the route segment contains "docs".
606
+ *
607
+ * @default "gradient"
608
+ */
609
+ docsBackground?: "dark" | "gradient" | (string & {});
535
610
  }
536
611
  //#endregion
537
612
  export type { ArticleSchemaOptions, BreadcrumbItem, LlmsFullTxtOptions, LlmsPage, LlmsTxtOptions };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rimelight/seo",
3
- "version": "0.0.8",
3
+ "version": "0.0.9",
4
4
  "private": false,
5
5
  "description": "Rimelight Entertainment's SEO Package",
6
6
  "homepage": "https://rimelight.com/docs",
@@ -72,14 +72,9 @@
72
72
  "publishConfig": {
73
73
  "access": "public"
74
74
  },
75
- "scripts": {
76
- "build": "vp pack",
77
- "prepack": "pnpm run build",
78
- "check": "vp check --fix && astro check"
79
- },
80
75
  "devDependencies": {
81
- "@rimelight/config": "workspace:*",
82
- "astro": "7.3.2",
76
+ "@rimelight/config": "0.0.12",
77
+ "astro": "7.3.3",
83
78
  "sharp": "^0.35.4",
84
79
  "typescript": "6.0.3"
85
80
  },
@@ -97,7 +92,10 @@
97
92
  }
98
93
  },
99
94
  "engines": {
100
- "node": ">=26.8.2"
95
+ "node": ">=26.9.0"
101
96
  },
102
- "packageManager": "pnpm@12.4.1"
103
- }
97
+ "scripts": {
98
+ "build": "vp pack",
99
+ "check": "vp check --fix && astro check"
100
+ }
101
+ }
package/src/env.d.ts CHANGED
@@ -1,55 +1,66 @@
1
- declare module "virtual:rimelight-seo-config" {
2
- const config: import("./integration.js").RimelightSeoOptions
3
- export default config
4
- }
5
-
6
- declare module "virtual:rimelight-seo/options" {
7
- const config: import("./integration.js").RimelightSeoOptions
8
- export default config
9
- }
10
-
11
- declare module "virtual:rimelight-seo/config" {
12
- export const SEO_LOCALES: import("./types.js").LocaleConfig | undefined
13
- export const PRIVATE_PATH_PREFIXES: string[] | undefined
14
- export const seoEntries: (() => Promise<import("./types.js").SeoEntry[]>) | undefined
15
- export const rssEntries: (() => Promise<import("./types.js").RssItem[]>) | undefined
16
- }
17
-
18
- declare module "virtual:rimelight-seo/site-config" {
19
- export const siteConfig: import("./types.js").SiteConfig | Record<string, any>
20
- }
21
-
22
- declare module "virtual:rimelight-seo/db" {
23
- export const db: any
24
- export const pages: any
25
- }
26
-
27
- declare module "virtual:rimelight-seo/corpus" {
28
- export const corpusPages: any[]
29
- }
30
-
31
- declare module "#db/schema" {
32
- export const pages: any
33
- }
34
-
35
- declare module "drizzle-orm" {
36
- export const and: (...args: any[]) => any
37
- export const isNull: (column: any) => any
38
- export const isNotNull: (column: any) => any
39
- export const sql: any
40
- export const eq: (column: any, value: any) => any
41
- }
42
-
43
- declare module "@rimelight/cms" {
44
- export const renderCorpusMarkdown: (db: any, options: any) => Promise<string>
45
- }
46
-
47
- declare module "astro:content" {
48
- export const getCollection: (collection: string) => Promise<any[]>
49
- }
50
-
51
- declare module "virtual:rimelight-seo/assets" {
52
- export const masterIconBuffer: Buffer | null
53
- export const isSvg: boolean
54
- export const backgroundColor: string
55
- }
1
+ declare module "virtual:rimelight/seo" {
2
+ export const siteConfig: import("./types.js").SiteConfig
3
+ export const SEO_LOCALES: import("./types.js").LocaleConfig | Record<string, string> | undefined
4
+ export const PRIVATE_PATH_PREFIXES: string[]
5
+ export const seoConfig: {
6
+ SEO_LOCALES?: import("./types.js").LocaleConfig | Record<string, string>
7
+ PRIVATE_PATH_PREFIXES: string[]
8
+ }
9
+ export default siteConfig
10
+ }
11
+
12
+ declare module "virtual:rimelight-seo-config" {
13
+ const config: import("./integration.js").RimelightSeoOptions
14
+ export default config
15
+ }
16
+
17
+ declare module "virtual:rimelight-seo/options" {
18
+ const config: import("./integration.js").RimelightSeoOptions
19
+ export default config
20
+ }
21
+
22
+ declare module "virtual:rimelight-seo/config" {
23
+ export const SEO_LOCALES: import("./types.js").LocaleConfig | undefined
24
+ export const PRIVATE_PATH_PREFIXES: string[] | undefined
25
+ export const seoEntries: (() => Promise<import("./types.js").SeoEntry[]>) | undefined
26
+ export const rssEntries: (() => Promise<import("./types.js").RssItem[]>) | undefined
27
+ }
28
+
29
+ declare module "virtual:rimelight-seo/site-config" {
30
+ export const siteConfig: import("./types.js").SiteConfig | Record<string, any>
31
+ }
32
+
33
+ declare module "virtual:rimelight-seo/db" {
34
+ export const db: any
35
+ export const pages: any
36
+ }
37
+
38
+ declare module "virtual:rimelight-seo/corpus" {
39
+ export const corpusPages: any[]
40
+ }
41
+
42
+ declare module "#db/schema" {
43
+ export const pages: any
44
+ }
45
+
46
+ declare module "drizzle-orm" {
47
+ export const and: (...args: any[]) => any
48
+ export const isNull: (column: any) => any
49
+ export const isNotNull: (column: any) => any
50
+ export const sql: any
51
+ export const eq: (column: any, value: any) => any
52
+ }
53
+
54
+ declare module "@rimelight/cms" {
55
+ export const renderCorpusMarkdown: (db: any, options: any) => Promise<string>
56
+ }
57
+
58
+ declare module "astro:content" {
59
+ export const getCollection: (collection: string) => Promise<any[]>
60
+ }
61
+
62
+ declare module "virtual:rimelight-seo/assets" {
63
+ export const masterIconBuffer: Buffer | null
64
+ export const isSvg: boolean
65
+ export const backgroundColor: string
66
+ }
package/src/index.ts CHANGED
@@ -66,6 +66,7 @@ export { buildApiCatalog } from "./catalog.js"
66
66
  export { buildArticleSchema, buildBreadcrumbSchema } from "./schema.js"
67
67
  export { generatePng, generateMaskablePng, generateIco, generateOgCard } from "./assets.js"
68
68
  export type { OgFontConfig, OgLayoutOptions, OgRenderConfig } from "./og.js"
69
+ export type { OgRouteOptions } from "./types.js"
69
70
 
70
71
  import { rimelightSeo } from "./integration.js"
71
72
  export type { RimelightSeoOptions } from "./integration.js"
@@ -23,27 +23,57 @@ export function rimelightSeo(options: RimelightSeoOptions = {}): AstroIntegratio
23
23
  return null
24
24
  }
25
25
 
26
- const seoConfigPath = findFile([
27
- "src/config/seo.config.ts",
28
- "src/config/seo.config.js",
29
- "src/config/seo.config.mjs"
30
- ])
31
-
32
- const hasConfigExport = (name: string): boolean => {
33
- if (!seoConfigPath) return false
34
- const content = fs.readFileSync(seoConfigPath, "utf8")
35
- return (
36
- new RegExp(
37
- `export\\s+(?:const|let|var|async\\s+function|function|class)\\s+${name}\\b`
38
- ).test(content) || new RegExp(`export\\s*\\{[^}]*\\b${name}\\b}`).test(content)
39
- )
26
+ const siteOpt = options.site || {}
27
+ const normalizedSiteConfig = {
28
+ id: siteOpt.id || options.id || "",
29
+ name: siteOpt.name || options.name || "",
30
+ description: siteOpt.description || options.description || "",
31
+ url: siteOpt.url || options.url || "",
32
+ ogImage: siteOpt.ogImage || options.ogImage || undefined,
33
+ author: siteOpt.author || options.author || siteOpt.name || options.name || "",
34
+ email: siteOpt.email || options.email || "",
35
+ branding: {
36
+ logo: {
37
+ alt:
38
+ siteOpt.branding?.logo?.alt ||
39
+ options.branding?.logo?.alt ||
40
+ siteOpt.name ||
41
+ options.name ||
42
+ ""
43
+ },
44
+ colors: {
45
+ themeColor:
46
+ siteOpt.branding?.colors?.themeColor ||
47
+ options.branding?.colors?.themeColor ||
48
+ "#ffffff",
49
+ backgroundColor:
50
+ siteOpt.branding?.colors?.backgroundColor ||
51
+ options.branding?.colors?.backgroundColor ||
52
+ "#ffffff"
53
+ },
54
+ ...siteOpt.branding,
55
+ ...options.branding
56
+ },
57
+ seo: {
58
+ titleTemplate:
59
+ siteOpt.seo?.titleTemplate ||
60
+ options.titleTemplate ||
61
+ (options.name ? `%s | ${options.name}` : "%s"),
62
+ maxDescriptionLength:
63
+ siteOpt.seo?.maxDescriptionLength || options.maxDescriptionLength || 160,
64
+ authorHandle: siteOpt.seo?.authorHandle || options.authorHandle || undefined,
65
+ ...siteOpt.seo
66
+ },
67
+ ...(siteOpt.openGraph || options.openGraph
68
+ ? { openGraph: siteOpt.openGraph || options.openGraph }
69
+ : {}),
70
+ ...(siteOpt.twitter || options.twitter
71
+ ? { twitter: siteOpt.twitter || options.twitter }
72
+ : {})
40
73
  }
41
74
 
42
- const siteConfigPath = findFile([
43
- "src/config/site.config.ts",
44
- "src/config/site.config.js",
45
- "src/config/site.config.mjs"
46
- ])
75
+ const seoLocales = options.locales
76
+ const privatePathPrefixes = options.privatePathPrefixes || []
47
77
 
48
78
  const dbPath = findFile(["src/db/index.ts", "src/db/index.js", "src/db/index.mjs"])
49
79
 
@@ -100,6 +130,10 @@ export function rimelightSeo(options: RimelightSeoOptions = {}): AstroIntegratio
100
130
  pattern,
101
131
  entrypoint: "@rimelight/seo/routes/sitemap.xml.ts"
102
132
  })
133
+ injectRoute({
134
+ pattern: "/sitemap-index.xml",
135
+ entrypoint: "@rimelight/seo/routes/sitemap-index.xml.ts"
136
+ })
103
137
  }
104
138
 
105
139
  if (options.rss !== false) {
@@ -173,6 +207,17 @@ export function rimelightSeo(options: RimelightSeoOptions = {}): AstroIntegratio
173
207
  })
174
208
  }
175
209
 
210
+ if (options.og !== false) {
211
+ const pattern =
212
+ typeof options.og === "object" && options.og.path
213
+ ? options.og.path
214
+ : "/open-graph/[...route].png"
215
+ injectRoute({
216
+ pattern,
217
+ entrypoint: "@rimelight/seo/routes/open-graph.[...route].png.ts"
218
+ })
219
+ }
220
+
176
221
  updateConfig({
177
222
  vite: {
178
223
  plugins: [
@@ -298,6 +343,9 @@ export function rimelightSeo(options: RimelightSeoOptions = {}): AstroIntegratio
298
343
  ) {
299
344
  return "\0virtual:rimelight-seo/options"
300
345
  }
346
+ if (id === "virtual:rimelight/seo") {
347
+ return "\0virtual:rimelight/seo"
348
+ }
301
349
  if (id === "virtual:rimelight-seo/config") {
302
350
  return "\0virtual:rimelight-seo/config"
303
351
  }
@@ -316,27 +364,26 @@ export function rimelightSeo(options: RimelightSeoOptions = {}): AstroIntegratio
316
364
  if (id === "\0virtual:rimelight-seo/options") {
317
365
  return `export default ${JSON.stringify(options)};`
318
366
  }
367
+ if (id === "\0virtual:rimelight/seo") {
368
+ return `export const siteConfig = ${JSON.stringify(normalizedSiteConfig)};
369
+ export const SEO_LOCALES = ${JSON.stringify(seoLocales)};
370
+ export const PRIVATE_PATH_PREFIXES = ${JSON.stringify(privatePathPrefixes)};
371
+ export const seoConfig = {
372
+ SEO_LOCALES: ${JSON.stringify(seoLocales)},
373
+ PRIVATE_PATH_PREFIXES: ${JSON.stringify(privatePathPrefixes)}
374
+ };
375
+ export default siteConfig;`
376
+ }
319
377
  if (id === "\0virtual:rimelight-seo/config") {
320
- if (seoConfigPath) {
321
- const seoEntriesExport = hasConfigExport("seoEntries")
322
- const rssEntriesExport = hasConfigExport("rssEntries")
323
- return `import * as customConfig from ${JSON.stringify(seoConfigPath)};
324
- export const SEO_LOCALES = customConfig.SEO_LOCALES;
325
- export const PRIVATE_PATH_PREFIXES = customConfig.PRIVATE_PATH_PREFIXES || [];
326
- export const seoEntries = ${seoEntriesExport ? "customConfig.seoEntries" : "undefined"};
327
- export const rssEntries = ${rssEntriesExport ? "customConfig.rssEntries" : "undefined"};
328
- export default customConfig;`
329
- }
330
- return `export const SEO_LOCALES = undefined;
331
- export const PRIVATE_PATH_PREFIXES = [];
378
+ return `export const SEO_LOCALES = ${JSON.stringify(seoLocales)};
379
+ export const PRIVATE_PATH_PREFIXES = ${JSON.stringify(privatePathPrefixes)};
332
380
  export const seoEntries = async () => [];
333
- export const rssEntries = undefined;`
381
+ export const rssEntries = undefined;
382
+ export default { SEO_LOCALES: ${JSON.stringify(seoLocales)}, PRIVATE_PATH_PREFIXES: ${JSON.stringify(privatePathPrefixes)} };`
334
383
  }
335
384
  if (id === "\0virtual:rimelight-seo/site-config") {
336
- if (siteConfigPath) {
337
- return `export * from ${JSON.stringify(siteConfigPath)};`
338
- }
339
- return `export const siteConfig = { name: "Site", description: "", url: "" };`
385
+ return `export const siteConfig = ${JSON.stringify(normalizedSiteConfig)};
386
+ export default siteConfig;`
340
387
  }
341
388
  if (id === "\0virtual:rimelight-seo/db") {
342
389
  if (dbPath) {
@@ -0,0 +1,81 @@
1
+ import type { APIRoute } from "astro"
2
+ import { renderDefaultOg } from "../og.js"
3
+ import * as siteConfigMod from "virtual:rimelight-seo/site-config"
4
+ import * as dbMod from "virtual:rimelight-seo/db"
5
+ import seoOptions from "virtual:rimelight-seo/options"
6
+
7
+ export const prerender = false
8
+
9
+ function getLocalizedText(val: unknown, locale = "en"): string {
10
+ if (typeof val === "object" && val !== null) {
11
+ const record = val as Record<string, string>
12
+ return record[locale] || Object.values(record)[0] || ""
13
+ }
14
+ if (typeof val === "string") return val
15
+ if (typeof val === "number" || typeof val === "boolean") return String(val)
16
+ return ""
17
+ }
18
+
19
+ export const GET: APIRoute = async ({ request }) => {
20
+ const url = new URL(request.url)
21
+ const routeParam = url.pathname.replace(/^\/open-graph\//, "").replace(/\.png$/, "")
22
+
23
+ const siteConfig = siteConfigMod.siteConfig || {}
24
+ const ogOpts = typeof seoOptions?.og === "object" ? seoOptions.og : {}
25
+
26
+ // Brand: explicit og.brand > seo.name > site.name > hostname
27
+ const brand = ogOpts.brand || (seoOptions as any)?.name || siteConfig.name || url.hostname
28
+
29
+ const defaultBg = ogOpts.defaultBackground ?? "dark"
30
+ const docsBg = ogOpts.docsBackground ?? "gradient"
31
+
32
+ let title = url.searchParams.get("title") || ""
33
+ let description = url.searchParams.get("description") || ""
34
+ let date = url.searchParams.get("pubDate") || ""
35
+ const badge =
36
+ url.searchParams.get("type") ||
37
+ (routeParam.includes("blog") ? "Blog Post" : routeParam.includes("legal") ? "Legal" : "")
38
+
39
+ // DB slug lookup -- same pattern as llms.txt.ts, gracefully skipped when db is absent
40
+ if (!title && routeParam && routeParam !== "page" && routeParam !== "default" && dbMod.db) {
41
+ try {
42
+ const { pages: pagesTable } = await import("#db/schema").catch(() => ({ pages: null }))
43
+ const { eq, and, isNull } = await import("drizzle-orm")
44
+
45
+ if (pagesTable) {
46
+ const slug = routeParam.split("/").pop() ?? ""
47
+ const page = await dbMod.db
48
+ .select({
49
+ title: pagesTable.title,
50
+ description: pagesTable.description,
51
+ postedAt: pagesTable.postedAt
52
+ })
53
+ .from(pagesTable)
54
+ .where(and(eq(pagesTable.slug, slug), isNull(pagesTable.deletedAt)))
55
+ .limit(1)
56
+ .then((r: any[]) => r[0])
57
+ .catch(() => undefined)
58
+
59
+ if (page) {
60
+ title = getLocalizedText(page.title)
61
+ description = description || getLocalizedText(page.description)
62
+ if (page.postedAt && !date) date = new Date(page.postedAt).toLocaleDateString()
63
+ }
64
+ }
65
+ } catch {
66
+ // DB unavailable -- fall through to query-param values
67
+ }
68
+ }
69
+
70
+ const isDocs = url.searchParams.get("isDocs") === "true" || routeParam.includes("docs")
71
+
72
+ return renderDefaultOg({
73
+ title: title || brand,
74
+ description,
75
+ brand,
76
+ badge,
77
+ date,
78
+ background: isDocs ? docsBg : defaultBg,
79
+ preview: url.searchParams.get("preview") === "1"
80
+ })
81
+ }
@@ -0,0 +1,20 @@
1
+ import type { APIRoute } from "astro"
2
+ import { buildSitemapIndexXml } from "../sitemap.js"
3
+ import * as siteConfigMod from "virtual:rimelight-seo/site-config"
4
+ import seoOptions from "virtual:rimelight-seo/options"
5
+
6
+ export const GET: APIRoute = ({ site, url }) => {
7
+ const siteUrl = site ? site.toString() : siteConfigMod.siteConfig?.url || url.origin
8
+
9
+ const sitemapOpts = typeof seoOptions?.sitemap === "object" ? seoOptions.sitemap : {}
10
+ const sitemapPattern = typeof sitemapOpts.path === "string" ? sitemapOpts.path : "/sitemap.xml"
11
+
12
+ const xml = buildSitemapIndexXml([new URL(sitemapPattern, siteUrl).toString()])
13
+
14
+ return new Response(xml, {
15
+ headers: {
16
+ "Content-Type": "application/xml; charset=utf-8",
17
+ "Cache-Control": "public, max-age=3600, stale-while-revalidate=86400"
18
+ }
19
+ })
20
+ }
package/src/types.ts CHANGED
@@ -519,6 +519,47 @@ export interface AssetRouteOptions {
519
519
  }
520
520
 
521
521
  export interface RimelightSeoOptions {
522
+ /**
523
+ * Site identifier / domain (e.g. "rimelight.com")
524
+ */
525
+ id?: string
526
+ /**
527
+ * Primary site name (e.g. "Rimelight")
528
+ */
529
+ name?: string
530
+ /**
531
+ * Default site description
532
+ */
533
+ description?: string
534
+ /**
535
+ * Canonical origin URL (e.g. "https://rimelight.com")
536
+ */
537
+ url?: string
538
+ ogImage?: string
539
+ author?: string
540
+ email?: string
541
+ branding?: Partial<SiteConfig["branding"]>
542
+ titleTemplate?: string
543
+ maxDescriptionLength?: number
544
+ authorHandle?: string
545
+ openGraph?: OpenGraphMeta
546
+ twitter?: TwitterMeta
547
+ /**
548
+ * Full or partial SiteConfig object
549
+ */
550
+ site?: Partial<SiteConfig>
551
+ /**
552
+ * Locale mappings for hreflang / sitemaps
553
+ */
554
+ locales?: LocaleConfig | Record<string, string>
555
+ /**
556
+ * Path prefixes to exclude from sitemap and indexing
557
+ */
558
+ privatePathPrefixes?: string[]
559
+ /**
560
+ * Custom SEO entry generator function
561
+ */
562
+ entries?: () => Promise<SeoEntry[]> | SeoEntry[]
522
563
  /**
523
564
  * Sitemap configuration or false to disable automatic /sitemap.xml (default: true)
524
565
  */
@@ -558,6 +599,41 @@ export interface RimelightSeoOptions {
558
599
  * icons) from a single master icon (default: true)
559
600
  */
560
601
  assets?: boolean | AssetRouteOptions
602
+ /**
603
+ * Open Graph image route configuration, or false to disable automatic /open-graph/[...route].png
604
+ * (default: true). When enabled, the route reads brand/background from seo.name and injects a
605
+ * handler that supports query-param overrides and optional DB slug lookup.
606
+ */
607
+ og?: boolean | OgRouteOptions
608
+ }
609
+
610
+ /**
611
+ * Configuration for the auto-injected /open-graph/[...route].png route.
612
+ */
613
+ export interface OgRouteOptions {
614
+ /**
615
+ * Route pattern for the OG image handler.
616
+ *
617
+ * @default "/open-graph/[...route].png"
618
+ */
619
+ path?: string
620
+ /**
621
+ * Brand name shown top-left of every OG image. Defaults to seo.name / site.name from integration
622
+ * config.
623
+ */
624
+ brand?: string
625
+ /**
626
+ * Background style used for standard pages.
627
+ *
628
+ * @default "dark"
629
+ */
630
+ defaultBackground?: "dark" | "gradient" | (string & {})
631
+ /**
632
+ * Background style used when ?isDocs=true or the route segment contains "docs".
633
+ *
634
+ * @default "gradient"
635
+ */
636
+ docsBackground?: "dark" | "gradient" | (string & {})
561
637
  }
562
638
 
563
639
  export type { LlmsPage, LlmsTxtOptions, LlmsFullTxtOptions } from "./llms.js"