@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
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
//#region src/conventions.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* 约定文件类型标识。每个标识映射到 srcDir 下的一个固定文件名和输出 URL。
|
|
4
|
+
*/
|
|
5
|
+
type SeoConventionKind = 'sitemap' | 'robots' | 'manifest' | 'opengraph-image' | 'icon' | 'apple-icon';
|
|
6
|
+
interface SeoConventionDescriptor {
|
|
7
|
+
kind: SeoConventionKind;
|
|
8
|
+
/** srcDir 下的相对文件名(不含扩展名后缀,会尝试 .ts/.js/.mjs/.mts) */
|
|
9
|
+
fileName: string;
|
|
10
|
+
/** 注册的 GET 路由路径 */
|
|
11
|
+
routePath: string;
|
|
12
|
+
/** 默认 `Content-Type`(可被 handler 返回的 Response 覆盖) */
|
|
13
|
+
contentType: string;
|
|
14
|
+
/** 默认 `Cache-Control` */
|
|
15
|
+
cacheControl: string;
|
|
16
|
+
}
|
|
17
|
+
declare const SEO_CONVENTIONS: readonly SeoConventionDescriptor[];
|
|
18
|
+
/**
|
|
19
|
+
* 注册约定文件所需的最小 app 接口。`UbeanApp` 满足此接口,
|
|
20
|
+
* 但为避免 `@ubean/seo` → `@ubean/app` 的硬依赖(后者已经依赖前者的兄弟包),
|
|
21
|
+
* 此处用结构类型解耦。
|
|
22
|
+
*/
|
|
23
|
+
interface SeoConventionApp {
|
|
24
|
+
get(path: string, ...handlers: Array<(c: SeoConventionContext) => unknown>): unknown;
|
|
25
|
+
}
|
|
26
|
+
interface SeoConventionContext {
|
|
27
|
+
req: {
|
|
28
|
+
method: string;
|
|
29
|
+
path: string;
|
|
30
|
+
url: string;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
interface RegisterSeoConventionsOptions {
|
|
34
|
+
/**
|
|
35
|
+
* 项目 srcDir 绝对路径或相对 cwd 的路径。约定文件会在该目录根下查找。
|
|
36
|
+
*/
|
|
37
|
+
srcDir: string;
|
|
38
|
+
/**
|
|
39
|
+
* 显式启用/禁用子集。未指定时启用全部约定。
|
|
40
|
+
*/
|
|
41
|
+
enabled?: SeoConventionKind[];
|
|
42
|
+
/**
|
|
43
|
+
* 显式禁用子集(优先级高于 `enabled`)。
|
|
44
|
+
*/
|
|
45
|
+
disabled?: SeoConventionKind[];
|
|
46
|
+
/**
|
|
47
|
+
* 文件扩展名候选(默认 `.ts/.js/.mjs/.mts/.cjs`)。第一个存在的扩展名胜出。
|
|
48
|
+
*/
|
|
49
|
+
extensions?: string[];
|
|
50
|
+
}
|
|
51
|
+
interface LoadedConvention {
|
|
52
|
+
descriptor: SeoConventionDescriptor;
|
|
53
|
+
filePath: string;
|
|
54
|
+
handler: (c?: SeoConventionContext) => unknown;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* 在 srcDir 下查找约定文件。返回已加载的 `{ descriptor, filePath, handler }` 列表;
|
|
58
|
+
* 不存在的文件被静默跳过。
|
|
59
|
+
*/
|
|
60
|
+
declare function discoverSeoConventions(options: RegisterSeoConventionsOptions): Promise<LoadedConvention[]>;
|
|
61
|
+
/**
|
|
62
|
+
* 扫描 srcDir 下的 SEO 约定文件,为每个发现的文件注册 GET 路由。
|
|
63
|
+
*
|
|
64
|
+
* 调用方应在 `app.init()` *之前* 调用此函数,这样约定路由会和其它路由
|
|
65
|
+
* 一起在 `init()` 中被纳入。如果调用方在 `init()` 之后调用,需要确保
|
|
66
|
+
* app 实例支持动态添加路由(`UbeanApp` 支持)。
|
|
67
|
+
*
|
|
68
|
+
* @returns 已注册的约定 kind 列表(便于日志/调试)
|
|
69
|
+
*/
|
|
70
|
+
declare function registerSeoConventions(app: SeoConventionApp, options: RegisterSeoConventionsOptions): Promise<SeoConventionKind[]>;
|
|
71
|
+
/**
|
|
72
|
+
* 列出 srcDir 下存在的约定文件 kind(不加载)。用于在不启动 app 的情况下
|
|
73
|
+
* 检测项目使用了哪些约定(例如 CLI 信息展示)。
|
|
74
|
+
*/
|
|
75
|
+
declare function listSeoConventions(options: Pick<RegisterSeoConventionsOptions, 'srcDir' | 'extensions'>): SeoConventionKind[];
|
|
76
|
+
//#endregion
|
|
77
|
+
export { RegisterSeoConventionsOptions, SEO_CONVENTIONS, SeoConventionApp, SeoConventionContext, SeoConventionDescriptor, SeoConventionKind, discoverSeoConventions, listSeoConventions, registerSeoConventions };
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { createManifestResponse, createRobotsResponse, createSitemapResponse } from "./index.js";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { isAbsolute, join, resolve } from "pathe";
|
|
4
|
+
//#region src/conventions.ts
|
|
5
|
+
/**
|
|
6
|
+
* P9-05 文件约定 SEO
|
|
7
|
+
*
|
|
8
|
+
* 在 `src/` 根目录扫描以下约定文件(对齐 Next.js `app/` 文件约定):
|
|
9
|
+
*
|
|
10
|
+
* | 文件 | 输出路径 | 默认导出形状 |
|
|
11
|
+
* | ----------------------------- | ------------------------- | ----------------------------------------------------------------------- |
|
|
12
|
+
* | `src/sitemap.ts` | `GET /sitemap.xml` | `() => SitemapUrl[] \| Promise<SitemapUrl[]>` |
|
|
13
|
+
* | `src/robots.ts` | `GET /robots.txt` | `() => RobotsOptions \| RobotsOptions[]` |
|
|
14
|
+
* | `src/manifest.ts` | `GET /manifest.webmanifest` | `() => WebAppManifest` |
|
|
15
|
+
* | `src/opengraph-image.ts` | `GET /opengraph-image` | `() => Response \| Promise<Response>` (通常是 PNG) |
|
|
16
|
+
* | `src/icon.ts` | `GET /icon` | `() => Response \| Promise<Response>` |
|
|
17
|
+
* | `src/apple-icon.ts` | `GET /apple-icon` | `() => Response \| Promise<Response>` |
|
|
18
|
+
*
|
|
19
|
+
* 设计要点:
|
|
20
|
+
* - 纯运行时实现(无 Vite 插件依赖):用 `fs.access` 检测文件存在,`import()` 加载。
|
|
21
|
+
* - 不强依赖 `@ubean/app` —— 通过最小化的 `SeoConventionApp` 结构类型避免循环依赖。
|
|
22
|
+
* - 调用方负责在 `defineServer({ onAppCreate })` 或 `app.init()` 前调用
|
|
23
|
+
* `registerSeoConventions(app, { srcDir })`。`@ubean/app` 的 `UbeanAppOptions.seoConventions`
|
|
24
|
+
* 字段会自动调用此函数(参见 `@ubean/app` 的 `app.ts`)。
|
|
25
|
+
* - 文件不存在时静默跳过(不报错),允许项目按需采用约定。
|
|
26
|
+
* - 已注册的路由(用户在 `routes/` 显式定义)优先级不变 —— 约定文件在 `init()` 中
|
|
27
|
+
* 于用户路由 *之后* 注册,因此 Hono 路由匹配时显式路由优先。
|
|
28
|
+
*/
|
|
29
|
+
const SEO_CONVENTIONS = [
|
|
30
|
+
{
|
|
31
|
+
kind: "sitemap",
|
|
32
|
+
fileName: "sitemap",
|
|
33
|
+
routePath: "/sitemap.xml",
|
|
34
|
+
contentType: "application/xml; charset=utf-8",
|
|
35
|
+
cacheControl: "public, max-age=3600"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
kind: "robots",
|
|
39
|
+
fileName: "robots",
|
|
40
|
+
routePath: "/robots.txt",
|
|
41
|
+
contentType: "text/plain; charset=utf-8",
|
|
42
|
+
cacheControl: "public, max-age=3600"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
kind: "manifest",
|
|
46
|
+
fileName: "manifest",
|
|
47
|
+
routePath: "/manifest.webmanifest",
|
|
48
|
+
contentType: "application/manifest+json; charset=utf-8",
|
|
49
|
+
cacheControl: "public, max-age=86400"
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
kind: "opengraph-image",
|
|
53
|
+
fileName: "opengraph-image",
|
|
54
|
+
routePath: "/opengraph-image",
|
|
55
|
+
contentType: "image/png",
|
|
56
|
+
cacheControl: "public, max-age=86400"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
kind: "icon",
|
|
60
|
+
fileName: "icon",
|
|
61
|
+
routePath: "/icon",
|
|
62
|
+
contentType: "image/png",
|
|
63
|
+
cacheControl: "public, max-age=86400"
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
kind: "apple-icon",
|
|
67
|
+
fileName: "apple-icon",
|
|
68
|
+
routePath: "/apple-icon",
|
|
69
|
+
contentType: "image/png",
|
|
70
|
+
cacheControl: "public, max-age=86400"
|
|
71
|
+
}
|
|
72
|
+
];
|
|
73
|
+
const DEFAULT_EXTENSIONS = [
|
|
74
|
+
".ts",
|
|
75
|
+
".js",
|
|
76
|
+
".mjs",
|
|
77
|
+
".mts",
|
|
78
|
+
".cjs"
|
|
79
|
+
];
|
|
80
|
+
/**
|
|
81
|
+
* 在 srcDir 下查找约定文件。返回已加载的 `{ descriptor, filePath, handler }` 列表;
|
|
82
|
+
* 不存在的文件被静默跳过。
|
|
83
|
+
*/
|
|
84
|
+
async function discoverSeoConventions(options) {
|
|
85
|
+
const srcDir = isAbsolute(options.srcDir) ? options.srcDir : resolve(process.cwd(), options.srcDir);
|
|
86
|
+
const extensions = options.extensions ?? DEFAULT_EXTENSIONS;
|
|
87
|
+
const disabled = new Set(options.disabled ?? []);
|
|
88
|
+
const enabledSet = options.enabled ? new Set(options.enabled) : null;
|
|
89
|
+
const loaded = [];
|
|
90
|
+
for (const descriptor of SEO_CONVENTIONS) {
|
|
91
|
+
if (disabled.has(descriptor.kind)) continue;
|
|
92
|
+
if (enabledSet && !enabledSet.has(descriptor.kind)) continue;
|
|
93
|
+
let filePath = null;
|
|
94
|
+
for (const ext of extensions) {
|
|
95
|
+
const candidate = join(srcDir, `${descriptor.fileName}${ext}`);
|
|
96
|
+
if (existsSync(candidate)) {
|
|
97
|
+
filePath = candidate;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (!filePath) continue;
|
|
102
|
+
try {
|
|
103
|
+
const handler = (await import(
|
|
104
|
+
/* @vite-ignore */
|
|
105
|
+
filePath
|
|
106
|
+
)).default;
|
|
107
|
+
if (typeof handler !== "function") continue;
|
|
108
|
+
loaded.push({
|
|
109
|
+
descriptor,
|
|
110
|
+
filePath,
|
|
111
|
+
handler
|
|
112
|
+
});
|
|
113
|
+
} catch {}
|
|
114
|
+
}
|
|
115
|
+
return loaded;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* 把单个约定文件的 default export 转换为 Hono GET handler。
|
|
119
|
+
*
|
|
120
|
+
* - `sitemap` / `robots` / `manifest`:调用 handler 拿到数据,用 `create*Response` 包装
|
|
121
|
+
* - 其它(`opengraph-image` / `icon` / `apple-icon`):handler 直接返回 `Response`
|
|
122
|
+
*/
|
|
123
|
+
async function toResponse(descriptor, handler, c) {
|
|
124
|
+
const result = await handler(c);
|
|
125
|
+
if (descriptor.kind === "sitemap") return createSitemapResponse(result);
|
|
126
|
+
if (descriptor.kind === "robots") return createRobotsResponse(result);
|
|
127
|
+
if (descriptor.kind === "manifest") return createManifestResponse(result);
|
|
128
|
+
if (result instanceof Response) {
|
|
129
|
+
if (!result.headers.get("Cache-Control")) {
|
|
130
|
+
const cloned = result.clone();
|
|
131
|
+
cloned.headers.set("Cache-Control", descriptor.cacheControl);
|
|
132
|
+
return cloned;
|
|
133
|
+
}
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
return new Response(`SEO convention "${descriptor.kind}" handler must return a Response (got ${typeof result})`, {
|
|
137
|
+
status: 500,
|
|
138
|
+
headers: { "Content-Type": "text/plain; charset=utf-8" }
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* 扫描 srcDir 下的 SEO 约定文件,为每个发现的文件注册 GET 路由。
|
|
143
|
+
*
|
|
144
|
+
* 调用方应在 `app.init()` *之前* 调用此函数,这样约定路由会和其它路由
|
|
145
|
+
* 一起在 `init()` 中被纳入。如果调用方在 `init()` 之后调用,需要确保
|
|
146
|
+
* app 实例支持动态添加路由(`UbeanApp` 支持)。
|
|
147
|
+
*
|
|
148
|
+
* @returns 已注册的约定 kind 列表(便于日志/调试)
|
|
149
|
+
*/
|
|
150
|
+
async function registerSeoConventions(app, options) {
|
|
151
|
+
const loaded = await discoverSeoConventions(options);
|
|
152
|
+
const registered = [];
|
|
153
|
+
for (const { descriptor, handler } of loaded) {
|
|
154
|
+
const routePath = descriptor.routePath;
|
|
155
|
+
app.get(routePath, async (c) => {
|
|
156
|
+
return await toResponse(descriptor, handler, c);
|
|
157
|
+
});
|
|
158
|
+
registered.push(descriptor.kind);
|
|
159
|
+
}
|
|
160
|
+
return registered;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* 列出 srcDir 下存在的约定文件 kind(不加载)。用于在不启动 app 的情况下
|
|
164
|
+
* 检测项目使用了哪些约定(例如 CLI 信息展示)。
|
|
165
|
+
*/
|
|
166
|
+
function listSeoConventions(options) {
|
|
167
|
+
const srcDir = isAbsolute(options.srcDir) ? options.srcDir : resolve(process.cwd(), options.srcDir);
|
|
168
|
+
const extensions = options.extensions ?? DEFAULT_EXTENSIONS;
|
|
169
|
+
const found = [];
|
|
170
|
+
for (const descriptor of SEO_CONVENTIONS) for (const ext of extensions) if (existsSync(join(srcDir, `${descriptor.fileName}${ext}`))) {
|
|
171
|
+
found.push(descriptor.kind);
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
return found;
|
|
175
|
+
}
|
|
176
|
+
//#endregion
|
|
177
|
+
export { SEO_CONVENTIONS, discoverSeoConventions, listSeoConventions, registerSeoConventions };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { JsonLdInput, JsonLdSchema, defineJsonLd, mergeJsonLd, renderJsonLdScript, renderJsonLdScripts, schemaOrg, useSchemaOrg } from "./json-ld.js";
|
|
1
2
|
import { UseSeoMetaInput } from "@unhead/vue";
|
|
2
3
|
//#region src/index.d.ts
|
|
3
4
|
interface RobotsOptions {
|
|
@@ -92,7 +93,27 @@ interface WebAppManifest {
|
|
|
92
93
|
dir?: 'ltr' | 'rtl' | 'auto';
|
|
93
94
|
}
|
|
94
95
|
declare function useSeoMeta<T extends Record<string, any>>(meta: T): T;
|
|
96
|
+
/**
|
|
97
|
+
* 对 meta tag 数组去重(后定义覆盖先定义)。
|
|
98
|
+
* 同 `name` 或同 `property` 的 tag 仅保留最后一个;无 name/property 的保留全部。
|
|
99
|
+
* 不修改入参数组,返回新数组。
|
|
100
|
+
*/
|
|
101
|
+
declare function dedupeMetaTags(tags: MetaTag[]): MetaTag[];
|
|
102
|
+
/**
|
|
103
|
+
* 对 link tag 数组去重(后定义覆盖先定义)。
|
|
104
|
+
* 同 `rel`(+hreflang+type+sizes)的 link 仅保留最后一个;无 rel 的保留全部。
|
|
105
|
+
* 不修改入参数组,返回新数组。
|
|
106
|
+
*/
|
|
107
|
+
declare function dedupeLinkTags(tags: LinkTag[]): LinkTag[];
|
|
95
108
|
declare function mergeMetadata(...metadatas: (SeoMetadata | undefined | null)[]): SeoMetadata;
|
|
109
|
+
/**
|
|
110
|
+
* 按"全局 → 布局 → 页面"优先级合并 SEO metadata(Task 8)。
|
|
111
|
+
* 页面级覆盖布局级,布局级覆盖全局级;`meta`/`link` 数组自动去重(last-wins)。
|
|
112
|
+
*
|
|
113
|
+
* 与 `mergeMetadata` 的关系:`mergeSeoLayers(g, l, p)` 等价于
|
|
114
|
+
* `mergeMetadata(g, l, p)`,仅以命名参数显式表达三层优先级,便于阅读。
|
|
115
|
+
*/
|
|
116
|
+
declare function mergeSeoLayers(global?: SeoMetadata | null, layout?: SeoMetadata | null, page?: SeoMetadata | null): SeoMetadata;
|
|
96
117
|
declare function buildMetaTags(meta: SeoMetadata): MetaTag[];
|
|
97
118
|
declare function buildLinkTags(meta: SeoMetadata): LinkTag[];
|
|
98
119
|
declare function buildTitle(meta: SeoMetadata, fallbackTitle?: string): string;
|
|
@@ -107,4 +128,4 @@ declare function createSitemapResponse(urls: SitemapUrl[]): Response;
|
|
|
107
128
|
declare function defineRobotsConfig(config: RobotsOptions[] | RobotsOptions): RobotsOptions[] | RobotsOptions;
|
|
108
129
|
declare function defineSitemapConfig(urls: SitemapUrl[] | (() => SitemapUrl[] | Promise<SitemapUrl[]>)): SitemapUrl[] | (() => SitemapUrl[] | Promise<SitemapUrl[]>);
|
|
109
130
|
//#endregion
|
|
110
|
-
export { LinkTag, ManifestIcon, MetaTag, OGImage, OpenGraphMeta, RobotsOptions, SeoMetadata, SitemapUrl, TwitterMeta, type UseSeoMetaInput, WebAppManifest, buildLinkTags, buildMetaTags, buildTitle, createManifestResponse, createRobotsResponse, createSitemapResponse, defineManifest, defineRobotsConfig, defineSitemapConfig, escapeXml, formatRobotsTxt, formatSitemapXml, mergeMetadata, renderHeadTags, useSeoMeta };
|
|
131
|
+
export { JsonLdInput, JsonLdSchema, LinkTag, ManifestIcon, MetaTag, OGImage, OpenGraphMeta, RobotsOptions, SeoMetadata, SitemapUrl, TwitterMeta, type UseSeoMetaInput, WebAppManifest, buildLinkTags, buildMetaTags, buildTitle, createManifestResponse, createRobotsResponse, createSitemapResponse, dedupeLinkTags, dedupeMetaTags, defineJsonLd, defineManifest, defineRobotsConfig, defineSitemapConfig, escapeXml, formatRobotsTxt, formatSitemapXml, mergeJsonLd, mergeMetadata, mergeSeoLayers, renderHeadTags, renderJsonLdScript, renderJsonLdScripts, schemaOrg, useSchemaOrg, useSeoMeta };
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { defineJsonLd, mergeJsonLd, renderJsonLdScript, renderJsonLdScripts, schemaOrg, useSchemaOrg } from "./json-ld.js";
|
|
1
2
|
import { useSeoMeta as useSeoMeta$1 } from "@unhead/vue";
|
|
2
3
|
//#region src/index.ts
|
|
3
4
|
function useSeoMeta(meta) {
|
|
@@ -6,6 +7,76 @@ function useSeoMeta(meta) {
|
|
|
6
7
|
} catch {}
|
|
7
8
|
return meta;
|
|
8
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* 计算 meta tag 的去重 key。
|
|
12
|
+
* - 优先用 `name` 属性(如 description / keywords / robots / twitter:*)
|
|
13
|
+
* - 其次用 `property` 属性(如 og:title / og:description)
|
|
14
|
+
* - 两者都没有时不参与去重(保留全部)
|
|
15
|
+
*/
|
|
16
|
+
function metaTagKey(tag) {
|
|
17
|
+
if (tag.name) return `name:${tag.name}`;
|
|
18
|
+
if (tag.property) return `property:${tag.property}`;
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* 计算 link tag 的去重 key。
|
|
23
|
+
* 基于 rel + 可选的 hreflang / type / sizes 组合,后定义覆盖先定义:
|
|
24
|
+
* - canonical:仅 rel 去重(页面级 canonical 覆盖布局级)
|
|
25
|
+
* - alternate:rel + hreflang 去重(不同 hreflang 共存)
|
|
26
|
+
* - icon:rel + sizes(+type)去重(不同尺寸/类型共存)
|
|
27
|
+
*/
|
|
28
|
+
function linkTagKey(tag) {
|
|
29
|
+
if (!tag.rel) return null;
|
|
30
|
+
const parts = [`rel:${tag.rel}`];
|
|
31
|
+
if (tag.hreflang) parts.push(`hreflang:${tag.hreflang}`);
|
|
32
|
+
if (tag.type) parts.push(`type:${tag.type}`);
|
|
33
|
+
if (tag.sizes) parts.push(`sizes:${tag.sizes}`);
|
|
34
|
+
return parts.join("|");
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* 对 meta tag 数组去重(后定义覆盖先定义)。
|
|
38
|
+
* 同 `name` 或同 `property` 的 tag 仅保留最后一个;无 name/property 的保留全部。
|
|
39
|
+
* 不修改入参数组,返回新数组。
|
|
40
|
+
*/
|
|
41
|
+
function dedupeMetaTags(tags) {
|
|
42
|
+
const result = [];
|
|
43
|
+
const seen = /* @__PURE__ */ new Map();
|
|
44
|
+
for (const tag of tags) {
|
|
45
|
+
const key = metaTagKey(tag);
|
|
46
|
+
if (key === null) {
|
|
47
|
+
result.push(tag);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const existingIdx = seen.get(key);
|
|
51
|
+
if (existingIdx === void 0) {
|
|
52
|
+
seen.set(key, result.length);
|
|
53
|
+
result.push(tag);
|
|
54
|
+
} else result[existingIdx] = tag;
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* 对 link tag 数组去重(后定义覆盖先定义)。
|
|
60
|
+
* 同 `rel`(+hreflang+type+sizes)的 link 仅保留最后一个;无 rel 的保留全部。
|
|
61
|
+
* 不修改入参数组,返回新数组。
|
|
62
|
+
*/
|
|
63
|
+
function dedupeLinkTags(tags) {
|
|
64
|
+
const result = [];
|
|
65
|
+
const seen = /* @__PURE__ */ new Map();
|
|
66
|
+
for (const tag of tags) {
|
|
67
|
+
const key = linkTagKey(tag);
|
|
68
|
+
if (key === null) {
|
|
69
|
+
result.push(tag);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const existingIdx = seen.get(key);
|
|
73
|
+
if (existingIdx === void 0) {
|
|
74
|
+
seen.set(key, result.length);
|
|
75
|
+
result.push(tag);
|
|
76
|
+
} else result[existingIdx] = tag;
|
|
77
|
+
}
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
9
80
|
function mergeMetadata(...metadatas) {
|
|
10
81
|
const result = {};
|
|
11
82
|
for (const meta of metadatas) {
|
|
@@ -36,8 +107,20 @@ function mergeMetadata(...metadatas) {
|
|
|
36
107
|
if (meta.meta) result.meta = [...result.meta || [], ...meta.meta];
|
|
37
108
|
if (meta.link) result.link = [...result.link || [], ...meta.link];
|
|
38
109
|
}
|
|
110
|
+
if (result.meta) result.meta = dedupeMetaTags(result.meta);
|
|
111
|
+
if (result.link) result.link = dedupeLinkTags(result.link);
|
|
39
112
|
return result;
|
|
40
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* 按"全局 → 布局 → 页面"优先级合并 SEO metadata(Task 8)。
|
|
116
|
+
* 页面级覆盖布局级,布局级覆盖全局级;`meta`/`link` 数组自动去重(last-wins)。
|
|
117
|
+
*
|
|
118
|
+
* 与 `mergeMetadata` 的关系:`mergeSeoLayers(g, l, p)` 等价于
|
|
119
|
+
* `mergeMetadata(g, l, p)`,仅以命名参数显式表达三层优先级,便于阅读。
|
|
120
|
+
*/
|
|
121
|
+
function mergeSeoLayers(global, layout, page) {
|
|
122
|
+
return mergeMetadata(global, layout, page);
|
|
123
|
+
}
|
|
41
124
|
function buildMetaTags(meta) {
|
|
42
125
|
const tags = [];
|
|
43
126
|
if (meta.description) tags.push({
|
|
@@ -99,31 +182,33 @@ function buildMetaTags(meta) {
|
|
|
99
182
|
property: "og:locale:alternate",
|
|
100
183
|
content: loc
|
|
101
184
|
});
|
|
102
|
-
if (og.image)
|
|
103
|
-
|
|
104
|
-
content: og.image
|
|
105
|
-
});
|
|
106
|
-
else {
|
|
107
|
-
tags.push({
|
|
185
|
+
if (og.image) {
|
|
186
|
+
if (typeof og.image === "string") tags.push({
|
|
108
187
|
property: "og:image",
|
|
109
|
-
content: og.image
|
|
110
|
-
});
|
|
111
|
-
if (og.image.width) tags.push({
|
|
112
|
-
property: "og:image:width",
|
|
113
|
-
content: String(og.image.width)
|
|
114
|
-
});
|
|
115
|
-
if (og.image.height) tags.push({
|
|
116
|
-
property: "og:image:height",
|
|
117
|
-
content: String(og.image.height)
|
|
118
|
-
});
|
|
119
|
-
if (og.image.alt) tags.push({
|
|
120
|
-
property: "og:image:alt",
|
|
121
|
-
content: og.image.alt
|
|
122
|
-
});
|
|
123
|
-
if (og.image.type) tags.push({
|
|
124
|
-
property: "og:image:type",
|
|
125
|
-
content: og.image.type
|
|
188
|
+
content: og.image
|
|
126
189
|
});
|
|
190
|
+
else {
|
|
191
|
+
tags.push({
|
|
192
|
+
property: "og:image",
|
|
193
|
+
content: og.image.url
|
|
194
|
+
});
|
|
195
|
+
if (og.image.width) tags.push({
|
|
196
|
+
property: "og:image:width",
|
|
197
|
+
content: String(og.image.width)
|
|
198
|
+
});
|
|
199
|
+
if (og.image.height) tags.push({
|
|
200
|
+
property: "og:image:height",
|
|
201
|
+
content: String(og.image.height)
|
|
202
|
+
});
|
|
203
|
+
if (og.image.alt) tags.push({
|
|
204
|
+
property: "og:image:alt",
|
|
205
|
+
content: og.image.alt
|
|
206
|
+
});
|
|
207
|
+
if (og.image.type) tags.push({
|
|
208
|
+
property: "og:image:type",
|
|
209
|
+
content: og.image.type
|
|
210
|
+
});
|
|
211
|
+
}
|
|
127
212
|
}
|
|
128
213
|
}
|
|
129
214
|
if (meta.twitter) {
|
|
@@ -167,8 +252,10 @@ function buildLinkTags(meta) {
|
|
|
167
252
|
}
|
|
168
253
|
function buildTitle(meta, fallbackTitle) {
|
|
169
254
|
let title = meta.title || fallbackTitle || "";
|
|
170
|
-
if (meta.titleTemplate && title)
|
|
171
|
-
|
|
255
|
+
if (meta.titleTemplate && title) {
|
|
256
|
+
if (typeof meta.titleTemplate === "function") title = meta.titleTemplate(title);
|
|
257
|
+
else title = meta.titleTemplate.replace("%s", title);
|
|
258
|
+
}
|
|
172
259
|
return title;
|
|
173
260
|
}
|
|
174
261
|
function renderHeadTags(meta, fallbackTitle) {
|
|
@@ -269,4 +356,4 @@ function defineSitemapConfig(urls) {
|
|
|
269
356
|
return urls;
|
|
270
357
|
}
|
|
271
358
|
//#endregion
|
|
272
|
-
export { buildLinkTags, buildMetaTags, buildTitle, createManifestResponse, createRobotsResponse, createSitemapResponse, defineManifest, defineRobotsConfig, defineSitemapConfig, escapeXml, formatRobotsTxt, formatSitemapXml, mergeMetadata, renderHeadTags, useSeoMeta };
|
|
359
|
+
export { buildLinkTags, buildMetaTags, buildTitle, createManifestResponse, createRobotsResponse, createSitemapResponse, dedupeLinkTags, dedupeMetaTags, defineJsonLd, defineManifest, defineRobotsConfig, defineSitemapConfig, escapeXml, formatRobotsTxt, formatSitemapXml, mergeJsonLd, mergeMetadata, mergeSeoLayers, renderHeadTags, renderJsonLdScript, renderJsonLdScripts, schemaOrg, useSchemaOrg, useSeoMeta };
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
//#region src/json-ld.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* P9-07 JSON-LD / Schema.org 结构化数据
|
|
4
|
+
*
|
|
5
|
+
* 提供 `defineJsonLd()` / `useSchemaOrg()` 两个 API,对齐 Nuxt `nuxt-schema.org` 的
|
|
6
|
+
* 用户体验,但实现保持零依赖(无 schema-dts 强约束),允许任意 JSON-LD 对象。
|
|
7
|
+
*
|
|
8
|
+
* 设计要点:
|
|
9
|
+
* - `defineJsonLd(schema)` 是纯函数:接收 Schema.org JSON-LD 对象(或返回它的函数),
|
|
10
|
+
* 返回标准的 `<script type="application/ld+json">` 标签字符串。
|
|
11
|
+
* - `useSchemaOrg(schema)` 是 Vue composable 版本,内部通过 `useHead` 注入到 head。
|
|
12
|
+
* 在非 Vue 上下文中静默降级(返回字符串)。
|
|
13
|
+
* - `renderJsonLdScript(schema)` 序列化为 HTML 字符串,自动转义 `</script>`、
|
|
14
|
+
* U+2028/U+2029(避免 JSON 中断 JS 解析)。
|
|
15
|
+
* - 支持 graph 数组(`@graph`)和多 schema 合并。
|
|
16
|
+
*
|
|
17
|
+
* 对齐:Nuxt `nuxt-schema.org`(基于 `schema-dts` 类型);Astro 手动注入。
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* 任意 JSON-LD 节点。必须是可序列化对象,通常包含 `@context` 和 `@type`。
|
|
21
|
+
*/
|
|
22
|
+
type JsonLdSchema = Record<string, unknown>;
|
|
23
|
+
/**
|
|
24
|
+
* JSON-LD schema 输入:可以是对象、对象数组,或返回对象/数组的函数。
|
|
25
|
+
*/
|
|
26
|
+
type JsonLdInput = JsonLdSchema | JsonLdSchema[] | (() => JsonLdSchema | JsonLdSchema[] | Promise<JsonLdSchema | JsonLdSchema[]>);
|
|
27
|
+
/**
|
|
28
|
+
* 渲染为 `<script type="application/ld+json">` HTML 字符串。
|
|
29
|
+
*
|
|
30
|
+
* 安全性:
|
|
31
|
+
* - `</script>` 拆分为 `<\/script>` 防止提前结束 script 标签
|
|
32
|
+
* - U+2028 / U+2029 替换为 Unicode 转义(防止 JS 解析器在 JSON 中断行)
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```ts
|
|
36
|
+
* renderJsonLdScript({ '@context': 'https://schema.org', '@type': 'Organization', name: 'ubean' })
|
|
37
|
+
* // => <script type="application/ld+json">{"@context":"https://schema.org","@type":"Organization","name":"ubean"}</script>
|
|
38
|
+
* ```
|
|
39
|
+
*/
|
|
40
|
+
declare function renderJsonLdScript(schema: JsonLdSchema | JsonLdSchema[]): string;
|
|
41
|
+
/**
|
|
42
|
+
* 把多个 JSON-LD schema 合并为 `@graph` 数组(对齐 schema.org 推荐做法)。
|
|
43
|
+
*
|
|
44
|
+
* 若输入只有一个 schema,直接返回(避免不必要的 @graph 包装)。
|
|
45
|
+
*/
|
|
46
|
+
declare function mergeJsonLd(schemas: JsonLdSchema[]): JsonLdSchema | JsonLdSchema[];
|
|
47
|
+
/**
|
|
48
|
+
* 定义一个 JSON-LD schema(纯函数,无副作用)。
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```ts
|
|
52
|
+
* const org = defineJsonLd({
|
|
53
|
+
* '@context': 'https://schema.org',
|
|
54
|
+
* '@type': 'Organization',
|
|
55
|
+
* name: 'ubean',
|
|
56
|
+
* url: 'https://ubean.dev'
|
|
57
|
+
* });
|
|
58
|
+
* // 在 SSR 中注入到 head:
|
|
59
|
+
* renderJsonLdScript(org)
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
declare function defineJsonLd(schema: JsonLdInput): JsonLdInput;
|
|
63
|
+
/**
|
|
64
|
+
* Vue composable:把 JSON-LD schema 注入到 head(通过 `useHead`)。
|
|
65
|
+
*
|
|
66
|
+
* 在非 Vue setup 上下文中静默降级(返回 schema 本身),不抛错。
|
|
67
|
+
*
|
|
68
|
+
* @example
|
|
69
|
+
* ```vue
|
|
70
|
+
* <script setup>
|
|
71
|
+
* useSchemaOrg({
|
|
72
|
+
* '@context': 'https://schema.org',
|
|
73
|
+
* '@type': 'Article',
|
|
74
|
+
* headline: 'My Article',
|
|
75
|
+
* author: { '@type': 'Person', name: 'John' }
|
|
76
|
+
* });
|
|
77
|
+
* </script>
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
declare function useSchemaOrg(schema: JsonLdInput): JsonLdInput;
|
|
81
|
+
/**
|
|
82
|
+
* 把多个 JSON-LD schema 渲染为 `<script>` 标签数组(SSR 用)。
|
|
83
|
+
*/
|
|
84
|
+
declare function renderJsonLdScripts(schemas: JsonLdSchema[]): string;
|
|
85
|
+
/**
|
|
86
|
+
* 常见 Schema.org 类型的便捷工厂函数(覆盖 80% 用例)。
|
|
87
|
+
*/
|
|
88
|
+
declare const schemaOrg: {
|
|
89
|
+
organization(options: {
|
|
90
|
+
name: string;
|
|
91
|
+
url?: string;
|
|
92
|
+
logo?: string;
|
|
93
|
+
sameAs?: string[];
|
|
94
|
+
}): JsonLdSchema;
|
|
95
|
+
website(options: {
|
|
96
|
+
name: string;
|
|
97
|
+
url: string;
|
|
98
|
+
description?: string;
|
|
99
|
+
}): JsonLdSchema;
|
|
100
|
+
article(options: {
|
|
101
|
+
headline: string;
|
|
102
|
+
author: string;
|
|
103
|
+
datePublished: string;
|
|
104
|
+
image?: string;
|
|
105
|
+
publisher?: string;
|
|
106
|
+
}): JsonLdSchema;
|
|
107
|
+
breadcrumb(items: Array<{
|
|
108
|
+
name: string;
|
|
109
|
+
url: string;
|
|
110
|
+
}>): JsonLdSchema;
|
|
111
|
+
product(options: {
|
|
112
|
+
name: string;
|
|
113
|
+
description?: string;
|
|
114
|
+
brand?: string;
|
|
115
|
+
sku?: string;
|
|
116
|
+
price?: string;
|
|
117
|
+
priceCurrency?: string;
|
|
118
|
+
availability?: "InStock" | "OutOfStock" | "PreOrder";
|
|
119
|
+
}): JsonLdSchema;
|
|
120
|
+
};
|
|
121
|
+
//#endregion
|
|
122
|
+
export { JsonLdInput, JsonLdSchema, defineJsonLd, mergeJsonLd, renderJsonLdScript, renderJsonLdScripts, schemaOrg, useSchemaOrg };
|