@rimelight/seo 0.0.15 → 0.0.18
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 +3 -2
- package/dist/assets.mjs +103 -48
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +2 -2
- package/dist/meta.d.mts +10 -2
- package/dist/meta.mjs +14 -3
- package/package.json +13 -19
- package/src/assets.test.ts +15 -5
- package/src/assets.ts +124 -58
- package/src/components/SEOHead.astro +15 -2
- package/src/index.ts +1 -0
- package/src/meta.test.ts +24 -0
- package/src/meta.ts +18 -2
- package/LICENSE +0 -21
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.
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
}
|
|
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.
|
|
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
|
|
24
|
-
const
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
*
|
|
39
|
-
|
|
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
|
-
*
|
|
54
|
-
|
|
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.
|
|
3
|
+
"version": "0.0.18",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Rimelight Entertainment's SEO Package",
|
|
6
6
|
"homepage": "https://rimelight.com/docs",
|
|
@@ -72,30 +72,24 @@
|
|
|
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
|
+
"dependencies": {
|
|
81
|
+
"takumi-js": "^2.14.0"
|
|
82
|
+
},
|
|
75
83
|
"devDependencies": {
|
|
76
|
-
"@rimelight/config": "
|
|
84
|
+
"@rimelight/config": "workspace:*",
|
|
77
85
|
"astro": "7.3.3",
|
|
78
|
-
"sharp": "^0.35.4",
|
|
79
86
|
"typescript": "6.0.3"
|
|
80
87
|
},
|
|
81
88
|
"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
|
-
}
|
|
89
|
+
"astro": ">=7.0.0"
|
|
93
90
|
},
|
|
94
91
|
"engines": {
|
|
95
92
|
"node": ">=26.9.0"
|
|
96
93
|
},
|
|
97
|
-
"
|
|
98
|
-
|
|
99
|
-
"check": "vp check --fix && astro check"
|
|
100
|
-
}
|
|
101
|
-
}
|
|
94
|
+
"packageManager": "pnpm@12.4.2"
|
|
95
|
+
}
|
package/src/assets.test.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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.
|
|
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
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
136
|
-
|
|
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
|
|
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
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
|
-
*
|
|
87
|
-
|
|
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">,
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
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.
|