@ubean/seo 0.1.13 → 0.2.0
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/conventions.d.ts +77 -0
- package/dist/conventions.js +177 -0
- package/dist/index.d.ts +22 -1
- package/dist/index.js +113 -26
- package/dist/json-ld.d.ts +122 -0
- package/dist/json-ld.js +159 -0
- package/dist/og-image.d.ts +196 -0
- package/dist/og-image.js +444 -0
- package/package.json +27 -5
package/dist/json-ld.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
//#region src/json-ld.ts
|
|
2
|
+
/**
|
|
3
|
+
* 渲染为 `<script type="application/ld+json">` HTML 字符串。
|
|
4
|
+
*
|
|
5
|
+
* 安全性:
|
|
6
|
+
* - `<\/script>` 拆分为 `<\/script>` 防止提前结束 script 标签
|
|
7
|
+
* - U+2028 / U+2029 替换为 Unicode 转义(防止 JS 解析器在 JSON 中断行)
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```ts
|
|
11
|
+
* renderJsonLdScript({ '@context': 'https://schema.org', '@type': 'Organization', name: 'ubean' })
|
|
12
|
+
* // => <script type="application/ld+json">{"@context":"https://schema.org","@type":"Organization","name":"ubean"}<\/script>
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
function renderJsonLdScript(schema) {
|
|
16
|
+
return `<script type="application/ld+json">${JSON.stringify(schema).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029")}<\/script>`;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* 把多个 JSON-LD schema 合并为 `@graph` 数组(对齐 schema.org 推荐做法)。
|
|
20
|
+
*
|
|
21
|
+
* 若输入只有一个 schema,直接返回(避免不必要的 @graph 包装)。
|
|
22
|
+
*/
|
|
23
|
+
function mergeJsonLd(schemas) {
|
|
24
|
+
if (schemas.length === 0) return {};
|
|
25
|
+
if (schemas.length === 1) return schemas[0];
|
|
26
|
+
if (schemas.every((s) => s["@context"] === schemas[0]["@context"]) && schemas[0]["@context"]) return {
|
|
27
|
+
"@context": schemas[0]["@context"],
|
|
28
|
+
"@graph": schemas.map(({ "@context": _c, ...rest }) => rest)
|
|
29
|
+
};
|
|
30
|
+
return { "@graph": schemas };
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* 定义一个 JSON-LD schema(纯函数,无副作用)。
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```ts
|
|
37
|
+
* const org = defineJsonLd({
|
|
38
|
+
* '@context': 'https://schema.org',
|
|
39
|
+
* '@type': 'Organization',
|
|
40
|
+
* name: 'ubean',
|
|
41
|
+
* url: 'https://ubean.dev'
|
|
42
|
+
* });
|
|
43
|
+
* // 在 SSR 中注入到 head:
|
|
44
|
+
* renderJsonLdScript(org)
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
function defineJsonLd(schema) {
|
|
48
|
+
return schema;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Vue composable:把 JSON-LD schema 注入到 head(通过 `useHead`)。
|
|
52
|
+
*
|
|
53
|
+
* 在非 Vue setup 上下文中静默降级(返回 schema 本身),不抛错。
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* ```vue
|
|
57
|
+
* <script setup>
|
|
58
|
+
* useSchemaOrg({
|
|
59
|
+
* '@context': 'https://schema.org',
|
|
60
|
+
* '@type': 'Article',
|
|
61
|
+
* headline: 'My Article',
|
|
62
|
+
* author: { '@type': 'Person', name: 'John' }
|
|
63
|
+
* });
|
|
64
|
+
* <\/script>
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
function useSchemaOrg(schema) {
|
|
68
|
+
try {
|
|
69
|
+
const head = globalThis.__UBEAN_HEAD__;
|
|
70
|
+
if (head && typeof head.push === "function") {
|
|
71
|
+
const scriptContent = typeof schema === "function" ? JSON.stringify(schema) : JSON.stringify(schema);
|
|
72
|
+
head.push({ script: [{
|
|
73
|
+
type: "application/ld+json",
|
|
74
|
+
innerHTML: scriptContent
|
|
75
|
+
}] });
|
|
76
|
+
}
|
|
77
|
+
} catch {}
|
|
78
|
+
return schema;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* 把多个 JSON-LD schema 渲染为 `<script>` 标签数组(SSR 用)。
|
|
82
|
+
*/
|
|
83
|
+
function renderJsonLdScripts(schemas) {
|
|
84
|
+
return schemas.map((s) => renderJsonLdScript(s)).join("\n");
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* 常见 Schema.org 类型的便捷工厂函数(覆盖 80% 用例)。
|
|
88
|
+
*/
|
|
89
|
+
const schemaOrg = {
|
|
90
|
+
organization(options) {
|
|
91
|
+
return {
|
|
92
|
+
"@context": "https://schema.org",
|
|
93
|
+
"@type": "Organization",
|
|
94
|
+
name: options.name,
|
|
95
|
+
...options.url ? { url: options.url } : {},
|
|
96
|
+
...options.logo ? { logo: options.logo } : {},
|
|
97
|
+
...options.sameAs ? { sameAs: options.sameAs } : {}
|
|
98
|
+
};
|
|
99
|
+
},
|
|
100
|
+
website(options) {
|
|
101
|
+
return {
|
|
102
|
+
"@context": "https://schema.org",
|
|
103
|
+
"@type": "WebSite",
|
|
104
|
+
name: options.name,
|
|
105
|
+
url: options.url,
|
|
106
|
+
...options.description ? { description: options.description } : {}
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
article(options) {
|
|
110
|
+
return {
|
|
111
|
+
"@context": "https://schema.org",
|
|
112
|
+
"@type": "Article",
|
|
113
|
+
headline: options.headline,
|
|
114
|
+
author: {
|
|
115
|
+
"@type": "Person",
|
|
116
|
+
name: options.author
|
|
117
|
+
},
|
|
118
|
+
datePublished: options.datePublished,
|
|
119
|
+
...options.image ? { image: options.image } : {},
|
|
120
|
+
...options.publisher ? { publisher: {
|
|
121
|
+
"@type": "Organization",
|
|
122
|
+
name: options.publisher
|
|
123
|
+
} } : {}
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
breadcrumb(items) {
|
|
127
|
+
return {
|
|
128
|
+
"@context": "https://schema.org",
|
|
129
|
+
"@type": "BreadcrumbList",
|
|
130
|
+
itemListElement: items.map((item, i) => ({
|
|
131
|
+
"@type": "ListItem",
|
|
132
|
+
position: i + 1,
|
|
133
|
+
name: item.name,
|
|
134
|
+
item: item.url
|
|
135
|
+
}))
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
product(options) {
|
|
139
|
+
return {
|
|
140
|
+
"@context": "https://schema.org",
|
|
141
|
+
"@type": "Product",
|
|
142
|
+
name: options.name,
|
|
143
|
+
...options.description ? { description: options.description } : {},
|
|
144
|
+
...options.brand ? { brand: {
|
|
145
|
+
"@type": "Brand",
|
|
146
|
+
name: options.brand
|
|
147
|
+
} } : {},
|
|
148
|
+
...options.sku ? { sku: options.sku } : {},
|
|
149
|
+
...options.price ? { offers: {
|
|
150
|
+
"@type": "Offer",
|
|
151
|
+
price: options.price,
|
|
152
|
+
...options.priceCurrency ? { priceCurrency: options.priceCurrency } : {},
|
|
153
|
+
...options.availability ? { availability: `https://schema.org/${options.availability}` } : {}
|
|
154
|
+
} } : {}
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
//#endregion
|
|
159
|
+
export { defineJsonLd, mergeJsonLd, renderJsonLdScript, renderJsonLdScripts, schemaOrg, useSchemaOrg };
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
//#region src/og-image.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Satori 兼容的 VDOM 节点。Satori 接受 React-like 元素树,这里用最小化结构类型
|
|
4
|
+
* 避免直接依赖 `satori` 类型(它是 optional peer 依赖)。
|
|
5
|
+
*
|
|
6
|
+
* 形状与 React.createElement 返回值兼容:`{ type, props: { children, style, ... } }`。
|
|
7
|
+
*/
|
|
8
|
+
interface SatoriNode {
|
|
9
|
+
type: string;
|
|
10
|
+
props: {
|
|
11
|
+
children?: SatoriNode | SatoriNode[] | string;
|
|
12
|
+
style?: Record<string, unknown>;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Satori 字体描述符。
|
|
18
|
+
*/
|
|
19
|
+
interface SatoriFont {
|
|
20
|
+
name: string;
|
|
21
|
+
data: ArrayBuffer | Buffer;
|
|
22
|
+
weight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;
|
|
23
|
+
style?: 'normal' | 'italic';
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* `ImageResponse` / `renderOgImage` 共享的渲染选项。
|
|
27
|
+
*/
|
|
28
|
+
interface OgImageOptions {
|
|
29
|
+
/** 图像宽度(px)。默认 1200。 */
|
|
30
|
+
width?: number;
|
|
31
|
+
/** 图像高度(px)。默认 630。 */
|
|
32
|
+
height?: number;
|
|
33
|
+
/**
|
|
34
|
+
* 字体列表。Satori 至少需要一个字体才能渲染。
|
|
35
|
+
* 用 `loadDefaultFont()` / `loadFontFromFile()` / `loadFontFromUrl()` 生成。
|
|
36
|
+
*/
|
|
37
|
+
fonts?: SatoriFont[];
|
|
38
|
+
/**
|
|
39
|
+
* 是否禁用 resvg 转换(只输出 SVG)。默认 false(输出 PNG)。
|
|
40
|
+
* 适用于调试或对 PNG 转换有自定义需求的场景。
|
|
41
|
+
*/
|
|
42
|
+
svgOnly?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* resvg 选项(透传给 `@resvg/resvg-js`)。可选。
|
|
45
|
+
*/
|
|
46
|
+
resvgOptions?: Record<string, unknown>;
|
|
47
|
+
/**
|
|
48
|
+
* 响应的 Cache-Control header。默认 `public, max-age=86400`。
|
|
49
|
+
*/
|
|
50
|
+
cacheControl?: string;
|
|
51
|
+
/**
|
|
52
|
+
* debug:开启 Satori debug 模式(输出额外日志)。默认 false。
|
|
53
|
+
*/
|
|
54
|
+
debug?: boolean;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* 内置模板的输入参数。
|
|
58
|
+
*/
|
|
59
|
+
interface OgTemplateInput {
|
|
60
|
+
/** 主标题(必填)。 */
|
|
61
|
+
title: string;
|
|
62
|
+
/** 副标题/描述。 */
|
|
63
|
+
description?: string;
|
|
64
|
+
/** 站点/作者名(显示在底部)。 */
|
|
65
|
+
siteName?: string;
|
|
66
|
+
/** Logo URL 或域名(显示在左上角)。 */
|
|
67
|
+
logo?: string;
|
|
68
|
+
/** 主题色(用于背景渐变)。 */
|
|
69
|
+
themeColor?: string;
|
|
70
|
+
/** 文本颜色。默认 `#ffffff`。 */
|
|
71
|
+
textColor?: string;
|
|
72
|
+
/** 背景图片 URL(可选,覆盖渐变)。 */
|
|
73
|
+
backgroundImage?: string;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* 默认字体 URL(使用 Google Fonts 的 Inter 字体,常规字重 400)。
|
|
77
|
+
*
|
|
78
|
+
* 在生产环境建议使用 `loadFontFromFile()` 加载本地字体,避免运行时网络依赖。
|
|
79
|
+
*/
|
|
80
|
+
declare const DEFAULT_FONT_URL = "https://fonts.gstatic.com/s/inter/v12/UcCO3FwrK3iLTeHuS_fvQtMwCp50KnMa1ZL7.woff2";
|
|
81
|
+
/**
|
|
82
|
+
* 默认字体名。
|
|
83
|
+
*/
|
|
84
|
+
declare const DEFAULT_FONT_NAME = "Inter";
|
|
85
|
+
/**
|
|
86
|
+
* 从 URL 加载字体(返回 Satori 兼容的 `SatoriFont`)。
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* ```ts
|
|
90
|
+
* const font = await loadFontFromUrl('https://fonts.gstatic.com/.../inter.woff2', 'Inter');
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
declare function loadFontFromUrl(url: string, name: string, weight?: SatoriFont['weight'], fontStyle?: SatoriFont['style']): Promise<SatoriFont>;
|
|
94
|
+
/**
|
|
95
|
+
* 从本地文件加载字体。
|
|
96
|
+
*
|
|
97
|
+
* @example
|
|
98
|
+
* ```ts
|
|
99
|
+
* const font = loadFontFromFile('./fonts/inter-regular.woff2', 'Inter');
|
|
100
|
+
* ```
|
|
101
|
+
*/
|
|
102
|
+
declare function loadFontFromFile(path: string, name: string, weight?: SatoriFont['weight'], fontStyle?: SatoriFont['style']): SatoriFont;
|
|
103
|
+
/**
|
|
104
|
+
* 加载默认字体(Inter Regular,从 Google Fonts CDN 拉取)。
|
|
105
|
+
*
|
|
106
|
+
* 注意:此函数依赖网络访问。Cloudflare Workers 等边缘环境需要 fetch API 可用。
|
|
107
|
+
* 离线/生产环境推荐用 `loadFontFromFile()` 替代。
|
|
108
|
+
*/
|
|
109
|
+
declare function loadDefaultFont(): Promise<SatoriFont>;
|
|
110
|
+
/**
|
|
111
|
+
* 默认 OG 图模板:渐变背景 + 居中标题 + 副标题 + 底部站点名。
|
|
112
|
+
*
|
|
113
|
+
* 返回 Satori 兼容的 VDOM 树。
|
|
114
|
+
*/
|
|
115
|
+
declare function defaultTemplate(input: OgTemplateInput): SatoriNode;
|
|
116
|
+
/**
|
|
117
|
+
* 文章模板:左对齐标题 + 日期/作者元信息。
|
|
118
|
+
*/
|
|
119
|
+
declare function articleTemplate(input: OgTemplateInput & {
|
|
120
|
+
author?: string;
|
|
121
|
+
date?: string;
|
|
122
|
+
}): SatoriNode;
|
|
123
|
+
/**
|
|
124
|
+
* 简单的颜色调亮/调暗工具(用于生成渐变背景)。
|
|
125
|
+
*
|
|
126
|
+
* `percent` 为负数调暗,正数调亮。范围 -100..100。
|
|
127
|
+
*/
|
|
128
|
+
declare function shadeColor(hex: string, percent: number): string;
|
|
129
|
+
/**
|
|
130
|
+
* 把 Satori VDOM 节点渲染为 PNG Buffer(或 SVG 字符串,当 `svgOnly: true` 时)。
|
|
131
|
+
*
|
|
132
|
+
* @returns `{ body, contentType }` —— body 为 Buffer/字符串,contentType 为 MIME 类型
|
|
133
|
+
*/
|
|
134
|
+
declare function renderToImage(node: SatoriNode, options?: OgImageOptions): Promise<{
|
|
135
|
+
body: Buffer | string;
|
|
136
|
+
contentType: string;
|
|
137
|
+
}>;
|
|
138
|
+
/**
|
|
139
|
+
* 对齐 Next.js `ImageResponse` 类。接收 Satori VDOM 节点,返回 PNG `Response`。
|
|
140
|
+
*
|
|
141
|
+
* 实现策略:body 使用 `ReadableStream`,在 stream 被 consume 时才执行实际的
|
|
142
|
+
* satori + resvg 渲染。这样构造函数可以保持同步,符合 Next.js
|
|
143
|
+
* `return new ImageResponse(...)` 的用法。
|
|
144
|
+
*
|
|
145
|
+
* @example
|
|
146
|
+
* ```ts
|
|
147
|
+
* // src/routes/og.ts
|
|
148
|
+
* import { ImageResponse, defaultTemplate, loadDefaultFont } from '@ubean/seo/og-image';
|
|
149
|
+
*
|
|
150
|
+
* export const GET = defineHandler(async () => {
|
|
151
|
+
* const fonts = [await loadDefaultFont()];
|
|
152
|
+
* const node = defaultTemplate({ title: 'Hello ubean', siteName: 'ubean' });
|
|
153
|
+
* return new ImageResponse(node, { fonts });
|
|
154
|
+
* });
|
|
155
|
+
* ```
|
|
156
|
+
*
|
|
157
|
+
* 也可用于 `src/opengraph-image.ts` 约定文件:
|
|
158
|
+
* ```ts
|
|
159
|
+
* // src/opengraph-image.ts
|
|
160
|
+
* import { ImageResponse, defaultTemplate, loadDefaultFont } from '@ubean/seo/og-image';
|
|
161
|
+
* export default async function GET() {
|
|
162
|
+
* const fonts = [await loadDefaultFont()];
|
|
163
|
+
* return new ImageResponse(defaultTemplate({ title: 'ubean' }), { fonts });
|
|
164
|
+
* }
|
|
165
|
+
* ```
|
|
166
|
+
*/
|
|
167
|
+
declare class ImageResponse extends Response {
|
|
168
|
+
constructor(node: SatoriNode, options?: OgImageOptions);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* 一步到位渲染 OG 图(模板 + 渲染 + Response)。
|
|
172
|
+
*
|
|
173
|
+
* @example
|
|
174
|
+
* ```ts
|
|
175
|
+
* // src/routes/og.ts
|
|
176
|
+
* import { renderOgImage } from '@ubean/seo/og-image';
|
|
177
|
+
* export const GET = defineHandler(async () => {
|
|
178
|
+
* return renderOgImage({ title: 'Hello world', siteName: 'ubean' });
|
|
179
|
+
* });
|
|
180
|
+
* ```
|
|
181
|
+
*/
|
|
182
|
+
declare function renderOgImage(input: OgTemplateInput, options?: OgImageOptions): Promise<Response>;
|
|
183
|
+
/**
|
|
184
|
+
* 用文章模板渲染 OG 图。
|
|
185
|
+
*/
|
|
186
|
+
declare function renderArticleOgImage(input: OgTemplateInput & {
|
|
187
|
+
author?: string;
|
|
188
|
+
date?: string;
|
|
189
|
+
}, options?: OgImageOptions): Promise<Response>;
|
|
190
|
+
/**
|
|
191
|
+
* 检测运行时是否安装了 OG 图渲染所需的依赖。
|
|
192
|
+
* 用于在不实际渲染的情况下判断能力可用性(例如 DevTools 信息展示)。
|
|
193
|
+
*/
|
|
194
|
+
declare function isOgImageSupported(): Promise<boolean>;
|
|
195
|
+
//#endregion
|
|
196
|
+
export { DEFAULT_FONT_NAME, DEFAULT_FONT_URL, ImageResponse, OgImageOptions, OgTemplateInput, SatoriFont, SatoriNode, articleTemplate, defaultTemplate, isOgImageSupported, loadDefaultFont, loadFontFromFile, loadFontFromUrl, renderArticleOgImage, renderOgImage, renderToImage, shadeColor };
|