@rimelight/seo 0.0.15 → 0.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/assets.d.mts CHANGED
@@ -21,10 +21,11 @@ export interface GenerateOgCardOptions {
21
21
  export declare function generatePng(source: Buffer | Uint8Array | string, options: {
22
22
  width: number;
23
23
  height: number;
24
+ background?: string;
24
25
  }): Promise<Buffer>;
25
26
  /**
26
- * Generate a maskable PWA PNG icon with safe-zone padding and solid background. The standard
27
- * recommendation is to place the master icon in the inner 80% safe zone.
27
+ * Generate a maskable PWA PNG icon with safe-zone padding and solid background. Standard
28
+ * recommendation is to place the master icon in the inner ~70-80% safe zone.
28
29
  */
29
30
  export declare function generateMaskablePng(source: Buffer | Uint8Array | string, options?: GenerateMaskableOptions): Promise<Buffer>;
30
31
  /**
package/dist/assets.mjs CHANGED
@@ -1,43 +1,86 @@
1
- import sharp from "sharp";
2
1
  //#region src/assets.ts
2
+ function toDataUrl(source) {
3
+ if (typeof source === "string") {
4
+ if (source.startsWith("data:") || source.startsWith("http:") || source.startsWith("https:")) return source;
5
+ const trimmed = source.trim();
6
+ if (trimmed.startsWith("<svg")) return `data:image/svg+xml;base64,${Buffer.from(trimmed).toString("base64")}`;
7
+ return source;
8
+ }
9
+ const buf = Buffer.from(source);
10
+ return `data:${buf.subarray(0, 100).toString("utf8").includes("<svg") ? "image/svg+xml" : "image/png"};base64,${buf.toString("base64")}`;
11
+ }
3
12
  /**
4
13
  * Generate a PNG buffer resized to specific dimensions from an SVG or image buffer.
5
14
  */
6
15
  async function generatePng(source, options) {
7
- return sharp(source).resize(options.width, options.height, {
8
- fit: "contain",
9
- background: {
10
- r: 0,
11
- g: 0,
12
- b: 0,
13
- alpha: 0
16
+ const { render } = await import("takumi-js");
17
+ const src = toDataUrl(source);
18
+ const png = await render({
19
+ type: "div",
20
+ props: {
21
+ style: {
22
+ display: "flex",
23
+ width: `${options.width}px`,
24
+ height: `${options.height}px`,
25
+ alignItems: "center",
26
+ justifyContent: "center",
27
+ backgroundColor: options.background || "transparent"
28
+ },
29
+ children: {
30
+ type: "img",
31
+ props: {
32
+ src,
33
+ style: {
34
+ width: "100%",
35
+ height: "100%",
36
+ objectFit: "contain"
37
+ }
38
+ }
39
+ }
14
40
  }
15
- }).png().toBuffer();
41
+ }, {
42
+ width: options.width,
43
+ height: options.height
44
+ });
45
+ return Buffer.from(png);
16
46
  }
17
47
  /**
18
- * Generate a maskable PWA PNG icon with safe-zone padding and solid background. The standard
19
- * recommendation is to place the master icon in the inner 80% safe zone.
48
+ * Generate a maskable PWA PNG icon with safe-zone padding and solid background. Standard
49
+ * recommendation is to place the master icon in the inner ~70-80% safe zone.
20
50
  */
21
51
  async function generateMaskablePng(source, options = {}) {
22
52
  const { size = 512, background = "#ffffff", paddingRatio = .15 } = options;
23
- const innerSize = Math.round(size * (1 - paddingRatio * 2));
24
- const pad = Math.floor((size - innerSize) / 2);
25
- const innerPng = await sharp(source).resize(innerSize, innerSize, {
26
- fit: "contain",
27
- background: {
28
- r: 0,
29
- g: 0,
30
- b: 0,
31
- alpha: 0
53
+ const { render } = await import("takumi-js");
54
+ const src = toDataUrl(source);
55
+ const innerPercent = `${Math.round((1 - paddingRatio * 2) * 100)}%`;
56
+ const png = await render({
57
+ type: "div",
58
+ props: {
59
+ style: {
60
+ display: "flex",
61
+ width: `${size}px`,
62
+ height: `${size}px`,
63
+ backgroundColor: background,
64
+ alignItems: "center",
65
+ justifyContent: "center"
66
+ },
67
+ children: {
68
+ type: "img",
69
+ props: {
70
+ src,
71
+ style: {
72
+ width: innerPercent,
73
+ height: innerPercent,
74
+ objectFit: "contain"
75
+ }
76
+ }
77
+ }
32
78
  }
33
- }).png().toBuffer();
34
- return sharp(innerPng).extend({
35
- top: pad,
36
- bottom: size - innerSize - pad,
37
- left: pad,
38
- right: size - innerSize - pad,
39
- background
40
- }).png().toBuffer();
79
+ }, {
80
+ width: size,
81
+ height: size
82
+ });
83
+ return Buffer.from(png);
41
84
  }
42
85
  /**
43
86
  * Generate a valid .ico buffer containing a 32x32 PNG image according to the Windows ICO
@@ -67,30 +110,42 @@ async function generateIco(source, size = 32) {
67
110
  */
68
111
  async function generateOgCard(options = {}) {
69
112
  const { width = 1200, height = 630, background = "#0a0a0a", logoBuffer } = options;
113
+ const { render } = await import("takumi-js");
114
+ const bgStyle = background.startsWith("linear-gradient") || background.startsWith("radial-gradient") ? { background } : { backgroundColor: background };
115
+ let children = null;
70
116
  if (logoBuffer) {
117
+ const src = toDataUrl(logoBuffer);
71
118
  const logoSize = Math.min(Math.round(height * .45), 280);
72
- const logoPng = await generatePng(logoBuffer, {
73
- width: logoSize,
74
- height: logoSize
75
- });
76
- const topPad = Math.floor((height - logoSize) / 2);
77
- const bottomPad = height - logoSize - topPad;
78
- const leftPad = Math.floor((width - logoSize) / 2);
79
- const rightPad = width - logoSize - leftPad;
80
- return sharp(logoPng).extend({
81
- top: topPad,
82
- bottom: bottomPad,
83
- left: leftPad,
84
- right: rightPad,
85
- background
86
- }).png().toBuffer();
119
+ children = {
120
+ type: "img",
121
+ props: {
122
+ src,
123
+ style: {
124
+ width: `${logoSize}px`,
125
+ height: `${logoSize}px`,
126
+ objectFit: "contain"
127
+ }
128
+ }
129
+ };
87
130
  }
88
- return sharp({ create: {
131
+ const png = await render({
132
+ type: "div",
133
+ props: {
134
+ style: {
135
+ display: "flex",
136
+ width: `${width}px`,
137
+ height: `${height}px`,
138
+ alignItems: "center",
139
+ justifyContent: "center",
140
+ ...bgStyle
141
+ },
142
+ ...children ? { children } : {}
143
+ }
144
+ }, {
89
145
  width,
90
- height,
91
- channels: 4,
92
- background
93
- } }).png().toBuffer();
146
+ height
147
+ });
148
+ return Buffer.from(png);
94
149
  }
95
150
  //#endregion
96
151
  export { generateIco, generateMaskablePng, generateOgCard, generatePng };
package/dist/index.d.mts CHANGED
@@ -6,9 +6,9 @@ import { buildApiCatalog } from "./catalog.mjs";
6
6
  import { buildSitemapIndexXml, buildSitemapUrls, buildSitemapXml, chunkSitemapUrls } from "./sitemap.mjs";
7
7
  import { buildRobotsTxt } from "./robots.mjs";
8
8
  import { buildRssXml, escapeXml } from "./rss.mjs";
9
- import { buildCanonicalUrl, buildPageTitle, buildRobotsMetaContent, buildSafeDescription, buildWebSiteSchema, deepMerge } from "./meta.mjs";
9
+ import { buildCanonicalUrl, buildDefaultOgImageUrl, buildPageTitle, buildRobotsMetaContent, buildSafeDescription, buildWebSiteSchema, deepMerge } from "./meta.mjs";
10
10
  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 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 };
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, buildDefaultOgImageUrl, buildLlmsFullTxt, buildLlmsTxt, buildPageTitle, buildRobotsMetaContent, buildRobotsTxt, buildRssXml, buildSafeDescription, buildSecurityTxt, buildSitemapIndexXml, buildSitemapUrls, buildSitemapXml, buildWebManifest, buildWebSiteSchema, chunkSitemapUrls, deepMerge, rimelightSeo as default, rimelightSeo, escapeXml, generateIco, generateMaskablePng, generateOgCard, generatePng };
package/dist/index.mjs CHANGED
@@ -3,7 +3,7 @@ import { buildApiCatalog } from "./catalog.mjs";
3
3
  import { buildSitemapIndexXml, buildSitemapUrls, buildSitemapXml, chunkSitemapUrls } from "./sitemap.mjs";
4
4
  import { buildRobotsTxt } from "./robots.mjs";
5
5
  import { buildRssXml, escapeXml } from "./rss.mjs";
6
- import { buildCanonicalUrl, buildPageTitle, buildRobotsMetaContent, buildSafeDescription, buildWebSiteSchema, deepMerge } from "./meta.mjs";
6
+ import { buildCanonicalUrl, buildDefaultOgImageUrl, buildPageTitle, buildRobotsMetaContent, buildSafeDescription, buildWebSiteSchema, deepMerge } from "./meta.mjs";
7
7
  import { buildLlmsFullTxt, buildLlmsTxt } from "./llms.mjs";
8
8
  import { buildSecurityTxt } from "./security.mjs";
9
9
  import { buildWebManifest } from "./manifest.mjs";
@@ -12,4 +12,4 @@ import rimelightSeo from "./integration.mjs";
12
12
  //#region src/index.ts
13
13
  var src_default = rimelightSeo;
14
14
  //#endregion
15
- export { buildApiCatalog, buildArticleSchema, buildBreadcrumbSchema, buildCanonicalUrl, buildLlmsFullTxt, buildLlmsTxt, buildPageTitle, buildRobotsMetaContent, buildRobotsTxt, buildRssXml, buildSafeDescription, buildSecurityTxt, buildSitemapIndexXml, buildSitemapUrls, buildSitemapXml, buildWebManifest, buildWebSiteSchema, chunkSitemapUrls, deepMerge, src_default as default, escapeXml, generateIco, generateMaskablePng, generateOgCard, generatePng, rimelightSeo };
15
+ export { buildApiCatalog, buildArticleSchema, buildBreadcrumbSchema, buildCanonicalUrl, buildDefaultOgImageUrl, buildLlmsFullTxt, buildLlmsTxt, buildPageTitle, buildRobotsMetaContent, buildRobotsTxt, buildRssXml, buildSafeDescription, buildSecurityTxt, buildSitemapIndexXml, buildSitemapUrls, buildSitemapXml, buildWebManifest, buildWebSiteSchema, chunkSitemapUrls, deepMerge, src_default as default, escapeXml, generateIco, generateMaskablePng, generateOgCard, generatePng, rimelightSeo };
package/dist/meta.d.mts CHANGED
@@ -35,8 +35,16 @@ export declare function buildRobotsMetaContent(opts: {
35
35
  override?: string;
36
36
  }): string;
37
37
  /**
38
- * \n * Build a Schema.org WebSite JSON-LD object.\n * Pass the result to JSON.stringify and emit
39
- * via <script type="application/ld+json">.\n
38
+ * Build dynamic Open Graph image URL fallback when no explicit image is provided.
39
+ */
40
+ export declare function buildDefaultOgImageUrl(opts: {
41
+ title?: string | undefined;
42
+ description?: string | undefined;
43
+ siteName?: string | undefined;
44
+ }): string;
45
+ /**
46
+ * Build a Schema.org WebSite JSON-LD object. Pass the result to JSON.stringify and emit via <script
47
+ * type="application/ld+json">.
40
48
  */
41
49
  export declare function buildWebSiteSchema(config: Pick<SiteConfig, "name" | "url" | "description">, site?: string): Record<string, unknown>;
42
50
  //#endregion
package/dist/meta.mjs CHANGED
@@ -50,8 +50,19 @@ function buildRobotsMetaContent(opts) {
50
50
  return !opts.noindex && !opts.is404 ? "index, follow, max-image-preview:large" : "noindex, nofollow";
51
51
  }
52
52
  /**
53
- * \n * Build a Schema.org WebSite JSON-LD object.\n * Pass the result to JSON.stringify and emit
54
- * via <script type="application/ld+json">.\n
53
+ * Build dynamic Open Graph image URL fallback when no explicit image is provided.
54
+ */
55
+ function buildDefaultOgImageUrl(opts) {
56
+ const params = new URLSearchParams();
57
+ const title = opts.title || opts.siteName;
58
+ if (title) params.set("title", title);
59
+ if (opts.description) params.set("description", opts.description);
60
+ const qs = params.toString();
61
+ return qs ? `/open-graph/page.png?${qs}` : "/open-graph/page.png";
62
+ }
63
+ /**
64
+ * Build a Schema.org WebSite JSON-LD object. Pass the result to JSON.stringify and emit via <script
65
+ * type="application/ld+json">.
55
66
  */
56
67
  function buildWebSiteSchema(config, site) {
57
68
  return {
@@ -63,4 +74,4 @@ function buildWebSiteSchema(config, site) {
63
74
  };
64
75
  }
65
76
  //#endregion
66
- export { buildCanonicalUrl, buildPageTitle, buildRobotsMetaContent, buildSafeDescription, buildWebSiteSchema, deepMerge };
77
+ export { buildCanonicalUrl, buildDefaultOgImageUrl, buildPageTitle, buildRobotsMetaContent, buildSafeDescription, buildWebSiteSchema, deepMerge };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rimelight/seo",
3
- "version": "0.0.15",
3
+ "version": "0.0.17",
4
4
  "private": false,
5
5
  "description": "Rimelight Entertainment's SEO Package",
6
6
  "homepage": "https://rimelight.com/docs",
@@ -72,24 +72,16 @@
72
72
  "publishConfig": {
73
73
  "access": "public"
74
74
  },
75
+ "dependencies": {
76
+ "takumi-js": "^2.14.0"
77
+ },
75
78
  "devDependencies": {
76
- "@rimelight/config": "0.0.18",
79
+ "@rimelight/config": "0.0.20",
77
80
  "astro": "7.3.3",
78
- "sharp": "^0.35.4",
79
81
  "typescript": "6.0.3"
80
82
  },
81
83
  "peerDependencies": {
82
- "astro": ">=7.0.0",
83
- "sharp": ">=0.33.0",
84
- "takumi-js": ">=2.0.0"
85
- },
86
- "peerDependenciesMeta": {
87
- "sharp": {
88
- "optional": true
89
- },
90
- "takumi-js": {
91
- "optional": true
92
- }
84
+ "astro": ">=7.0.0"
93
85
  },
94
86
  "engines": {
95
87
  "node": ">=26.9.0"
@@ -1,17 +1,27 @@
1
1
  import { describe, it, expect } from "vite-plus/test"
2
2
  import { generatePng, generateMaskablePng, generateIco, generateOgCard } from "./assets.js"
3
- import sharp from "sharp"
4
3
 
5
4
  const sampleSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
6
5
  <circle cx="50" cy="50" r="40" fill="#ff0000" />
7
6
  </svg>`
8
7
 
8
+ function getPngMetadata(buf: Buffer) {
9
+ // PNG signature: 8 bytes
10
+ const isPng =
11
+ buf.length >= 24 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47
12
+ return {
13
+ format: isPng ? "png" : "unknown",
14
+ width: buf.readUInt32BE(16),
15
+ height: buf.readUInt32BE(20)
16
+ }
17
+ }
18
+
9
19
  describe("Asset Generation", () => {
10
20
  it("generates PNG buffer with correct dimensions", async () => {
11
21
  const pngBuffer = await generatePng(Buffer.from(sampleSvg), { width: 192, height: 192 })
12
22
  expect(pngBuffer).toBeInstanceOf(Buffer)
13
23
 
14
- const meta = await sharp(pngBuffer).metadata()
24
+ const meta = getPngMetadata(pngBuffer)
15
25
  expect(meta.format).toBe("png")
16
26
  expect(meta.width).toBe(192)
17
27
  expect(meta.height).toBe(192)
@@ -24,7 +34,7 @@ describe("Asset Generation", () => {
24
34
  })
25
35
  expect(maskableBuffer).toBeInstanceOf(Buffer)
26
36
 
27
- const meta = await sharp(maskableBuffer).metadata()
37
+ const meta = getPngMetadata(maskableBuffer)
28
38
  expect(meta.format).toBe("png")
29
39
  expect(meta.width).toBe(512)
30
40
  expect(meta.height).toBe(512)
@@ -49,7 +59,7 @@ describe("Asset Generation", () => {
49
59
 
50
60
  // Verify PNG payload inside ICO
51
61
  const pngSlice = icoBuffer.subarray(22)
52
- const meta = await sharp(pngSlice).metadata()
62
+ const meta = getPngMetadata(pngSlice)
53
63
  expect(meta.format).toBe("png")
54
64
  expect(meta.width).toBe(32)
55
65
  expect(meta.height).toBe(32)
@@ -62,7 +72,7 @@ describe("Asset Generation", () => {
62
72
  })
63
73
  expect(ogBuffer).toBeInstanceOf(Buffer)
64
74
 
65
- const meta = await sharp(ogBuffer).metadata()
75
+ const meta = getPngMetadata(ogBuffer)
66
76
  expect(meta.format).toBe("png")
67
77
  expect(meta.width).toBe(1200)
68
78
  expect(meta.height).toBe(630)
package/src/assets.ts CHANGED
@@ -1,5 +1,3 @@
1
- import sharp from "sharp"
2
-
3
1
  export interface GeneratePngOptions {
4
2
  width: number
5
3
  height: number
@@ -19,52 +17,110 @@ export interface GenerateOgCardOptions {
19
17
  logoBuffer?: Buffer | Uint8Array | string | null
20
18
  }
21
19
 
20
+ function toDataUrl(source: Buffer | Uint8Array | string): string {
21
+ if (typeof source === "string") {
22
+ if (source.startsWith("data:") || source.startsWith("http:") || source.startsWith("https:")) {
23
+ return source
24
+ }
25
+ const trimmed = source.trim()
26
+ if (trimmed.startsWith("<svg")) {
27
+ return `data:image/svg+xml;base64,${Buffer.from(trimmed).toString("base64")}`
28
+ }
29
+ return source
30
+ }
31
+ const buf = Buffer.from(source)
32
+ const isSvg = buf.subarray(0, 100).toString("utf8").includes("<svg")
33
+ const mime = isSvg ? "image/svg+xml" : "image/png"
34
+ return `data:${mime};base64,${buf.toString("base64")}`
35
+ }
36
+
22
37
  /**
23
38
  * Generate a PNG buffer resized to specific dimensions from an SVG or image buffer.
24
39
  */
25
40
  export async function generatePng(
26
41
  source: Buffer | Uint8Array | string,
27
- options: { width: number; height: number }
42
+ options: { width: number; height: number; background?: string }
28
43
  ): Promise<Buffer> {
29
- return sharp(source)
30
- .resize(options.width, options.height, {
31
- fit: "contain",
32
- background: { r: 0, g: 0, b: 0, alpha: 0 }
33
- })
34
- .png()
35
- .toBuffer()
44
+ const { render } = await import("takumi-js")
45
+ const src = toDataUrl(source)
46
+
47
+ const vnode = {
48
+ type: "div",
49
+ props: {
50
+ style: {
51
+ display: "flex",
52
+ width: `${options.width}px`,
53
+ height: `${options.height}px`,
54
+ alignItems: "center",
55
+ justifyContent: "center",
56
+ backgroundColor: options.background || "transparent"
57
+ },
58
+ children: {
59
+ type: "img",
60
+ props: {
61
+ src,
62
+ style: {
63
+ width: "100%",
64
+ height: "100%",
65
+ objectFit: "contain"
66
+ }
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ const png = await render(vnode, {
73
+ width: options.width,
74
+ height: options.height
75
+ })
76
+
77
+ return Buffer.from(png)
36
78
  }
37
79
 
38
80
  /**
39
- * Generate a maskable PWA PNG icon with safe-zone padding and solid background. The standard
40
- * recommendation is to place the master icon in the inner 80% safe zone.
81
+ * Generate a maskable PWA PNG icon with safe-zone padding and solid background. Standard
82
+ * recommendation is to place the master icon in the inner ~70-80% safe zone.
41
83
  */
42
84
  export async function generateMaskablePng(
43
85
  source: Buffer | Uint8Array | string,
44
86
  options: GenerateMaskableOptions = {}
45
87
  ): Promise<Buffer> {
46
88
  const { size = 512, background = "#ffffff", paddingRatio = 0.15 } = options
47
- const innerSize = Math.round(size * (1 - paddingRatio * 2))
48
- const pad = Math.floor((size - innerSize) / 2)
49
-
50
- const innerPng = await sharp(source)
51
- .resize(innerSize, innerSize, {
52
- fit: "contain",
53
- background: { r: 0, g: 0, b: 0, alpha: 0 }
54
- })
55
- .png()
56
- .toBuffer()
57
-
58
- return sharp(innerPng)
59
- .extend({
60
- top: pad,
61
- bottom: size - innerSize - pad,
62
- left: pad,
63
- right: size - innerSize - pad,
64
- background
65
- })
66
- .png()
67
- .toBuffer()
89
+ const { render } = await import("takumi-js")
90
+ const src = toDataUrl(source)
91
+ const innerPercent = `${Math.round((1 - paddingRatio * 2) * 100)}%`
92
+
93
+ const vnode = {
94
+ type: "div",
95
+ props: {
96
+ style: {
97
+ display: "flex",
98
+ width: `${size}px`,
99
+ height: `${size}px`,
100
+ backgroundColor: background,
101
+ alignItems: "center",
102
+ justifyContent: "center"
103
+ },
104
+ children: {
105
+ type: "img",
106
+ props: {
107
+ src,
108
+ style: {
109
+ width: innerPercent,
110
+ height: innerPercent,
111
+ objectFit: "contain"
112
+ }
113
+ }
114
+ }
115
+ }
116
+ }
117
+
118
+ const png = await render(vnode, {
119
+ width: size,
120
+ height: size
121
+ })
122
+
123
+ return Buffer.from(png)
68
124
  }
69
125
 
70
126
  /**
@@ -103,35 +159,45 @@ export async function generateIco(
103
159
  */
104
160
  export async function generateOgCard(options: GenerateOgCardOptions = {}): Promise<Buffer> {
105
161
  const { width = 1200, height = 630, background = "#0a0a0a", logoBuffer } = options
162
+ const { render } = await import("takumi-js")
106
163
 
164
+ const bgStyle =
165
+ background.startsWith("linear-gradient") || background.startsWith("radial-gradient")
166
+ ? { background }
167
+ : { backgroundColor: background }
168
+
169
+ let children: any = null
107
170
  if (logoBuffer) {
171
+ const src = toDataUrl(logoBuffer)
108
172
  const logoSize = Math.min(Math.round(height * 0.45), 280)
109
- const logoPng = await generatePng(logoBuffer, { width: logoSize, height: logoSize })
110
- const topPad = Math.floor((height - logoSize) / 2)
111
- const bottomPad = height - logoSize - topPad
112
- const leftPad = Math.floor((width - logoSize) / 2)
113
- const rightPad = width - logoSize - leftPad
114
-
115
- return sharp(logoPng)
116
- .extend({
117
- top: topPad,
118
- bottom: bottomPad,
119
- left: leftPad,
120
- right: rightPad,
121
- background
122
- })
123
- .png()
124
- .toBuffer()
173
+ children = {
174
+ type: "img",
175
+ props: {
176
+ src,
177
+ style: {
178
+ width: `${logoSize}px`,
179
+ height: `${logoSize}px`,
180
+ objectFit: "contain"
181
+ }
182
+ }
183
+ }
125
184
  }
126
185
 
127
- return sharp({
128
- create: {
129
- width,
130
- height,
131
- channels: 4,
132
- background
186
+ const vnode = {
187
+ type: "div",
188
+ props: {
189
+ style: {
190
+ display: "flex",
191
+ width: `${width}px`,
192
+ height: `${height}px`,
193
+ alignItems: "center",
194
+ justifyContent: "center",
195
+ ...bgStyle
196
+ },
197
+ ...(children ? { children } : {})
133
198
  }
134
- })
135
- .png()
136
- .toBuffer()
199
+ }
200
+
201
+ const png = await render(vnode, { width, height })
202
+ return Buffer.from(png)
137
203
  }
@@ -6,6 +6,7 @@ import {
6
6
  buildSafeDescription,
7
7
  buildRobotsMetaContent,
8
8
  buildWebSiteSchema,
9
+ buildDefaultOgImageUrl,
9
10
  deepMerge
10
11
  } from "../meta.js"
11
12
  import { buildArticleSchema, buildBreadcrumbSchema } from "../schema.js"
@@ -40,7 +41,6 @@ const {
40
41
  // Resolve defaults from config if available
41
42
  const siteName = config?.name || 'Rimelight'
42
43
  const siteDescription = config?.description || ''
43
- const ogImageFallback = config?.seo?.ogImageFallback || config?.ogImage || '/og.png'
44
44
  const themeColor = config?.branding?.colors?.themeColor
45
45
 
46
46
  const rawFavicon = customFavicon || (typeof config?.branding?.favicon === 'string' ? config.branding.favicon : config?.branding?.favicon?.svg) || config?.branding?.icon || '/favicon.svg'
@@ -70,7 +70,20 @@ const safeDescription = config?.seo?.maxDescriptionLength
70
70
  ? buildSafeDescription(finalDescription, config.seo.maxDescriptionLength)
71
71
  : finalDescription
72
72
 
73
- const finalOgImage = ogImage || (ogImageFallback ? { src: ogImageFallback, alt: siteName } : undefined)
73
+ const defaultDynamicOg = buildDefaultOgImageUrl({
74
+ title,
75
+ description: finalDescription,
76
+ siteName
77
+ })
78
+ const ogImageFallback = config?.seo?.ogImageFallback || config?.ogImage || defaultDynamicOg
79
+
80
+ const rawOgImage = Array.isArray(ogImage)
81
+ ? ogImage[0]
82
+ : typeof ogImage === 'string'
83
+ ? { src: ogImage, alt: siteName }
84
+ : ogImage
85
+
86
+ const finalOgImage = rawOgImage || (ogImageFallback ? (typeof ogImageFallback === 'string' ? { src: ogImageFallback, alt: siteName } : ogImageFallback) : undefined)
74
87
  const absoluteOgImage = finalOgImage
75
88
  ? new URL(finalOgImage.src, Astro.site ?? Astro.url).toString()
76
89
  : undefined
package/src/index.ts CHANGED
@@ -56,6 +56,7 @@ export {
56
56
  buildSafeDescription,
57
57
  buildRobotsMetaContent,
58
58
  buildWebSiteSchema,
59
+ buildDefaultOgImageUrl,
59
60
  deepMerge
60
61
  } from "./meta.js"
61
62
 
package/src/meta.test.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  buildSafeDescription,
6
6
  buildRobotsMetaContent,
7
7
  buildWebSiteSchema,
8
+ buildDefaultOgImageUrl,
8
9
  deepMerge
9
10
  } from "./meta.js"
10
11
 
@@ -132,3 +133,26 @@ describe("buildWebSiteSchema", () => {
132
133
  expect(schema["url"]).toBe("https://override.com")
133
134
  })
134
135
  })
136
+
137
+ describe("buildDefaultOgImageUrl", () => {
138
+ it("builds query params for title and description", () => {
139
+ const url = buildDefaultOgImageUrl({
140
+ title: "My Page",
141
+ description: "My description"
142
+ })
143
+ expect(url).toBe("/open-graph/page.png?title=My+Page&description=My+description")
144
+ })
145
+
146
+ it("falls back to siteName if title is missing", () => {
147
+ const url = buildDefaultOgImageUrl({
148
+ siteName: "Rimelight",
149
+ description: "Welcome"
150
+ })
151
+ expect(url).toBe("/open-graph/page.png?title=Rimelight&description=Welcome")
152
+ })
153
+
154
+ it("returns base path when no params provided", () => {
155
+ const url = buildDefaultOgImageUrl({})
156
+ expect(url).toBe("/open-graph/page.png")
157
+ })
158
+ })
package/src/meta.ts CHANGED
@@ -83,8 +83,24 @@ export function buildRobotsMetaContent(opts: {
83
83
  }
84
84
 
85
85
  /**
86
- * \n * Build a Schema.org WebSite JSON-LD object.\n * Pass the result to JSON.stringify and emit
87
- * via <script type="application/ld+json">.\n
86
+ * Build dynamic Open Graph image URL fallback when no explicit image is provided.
87
+ */
88
+ export function buildDefaultOgImageUrl(opts: {
89
+ title?: string | undefined
90
+ description?: string | undefined
91
+ siteName?: string | undefined
92
+ }): string {
93
+ const params = new URLSearchParams()
94
+ const title = opts.title || opts.siteName
95
+ if (title) params.set("title", title)
96
+ if (opts.description) params.set("description", opts.description)
97
+ const qs = params.toString()
98
+ return qs ? `/open-graph/page.png?${qs}` : "/open-graph/page.png"
99
+ }
100
+
101
+ /**
102
+ * Build a Schema.org WebSite JSON-LD object. Pass the result to JSON.stringify and emit via <script
103
+ * type="application/ld+json">.
88
104
  */
89
105
  export function buildWebSiteSchema(
90
106
  config: Pick<SiteConfig, "name" | "url" | "description">,