@ubean/seo 0.6.1 → 0.6.3

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/README.md ADDED
@@ -0,0 +1,257 @@
1
+ # @ubean/seo
2
+
3
+ **English | [中文](./README.zh-CN.md)**
4
+
5
+ SEO toolkit for ubean — metadata merging, head management, robots / sitemap / manifest responses, file conventions, JSON-LD and OG image generation.
6
+
7
+ `@ubean/seo` owns the **server-side and isomorphic SEO surface**: merging metadata across layers, rendering robots.txt / sitemap.xml / manifest.webmanifest, discovering the `src/sitemap.ts` style convention files, producing JSON-LD, and rendering Open Graph images behind an optional-peer subpath.
8
+
9
+ ```ts
10
+ import { useSeoMeta } from '@ubean/seo';
11
+
12
+ useSeoMeta({
13
+ title: 'ubean',
14
+ description: 'Full-stack Vue meta-framework',
15
+ openGraph: { type: 'website' }
16
+ });
17
+ ```
18
+
19
+ ## Table of Contents
20
+
21
+ - [Package Positioning](#package-positioning)
22
+ - [Install](#install)
23
+ - [Quick Start](#quick-start)
24
+ - [Metadata Merging](#metadata-merging)
25
+ - [Head Rendering](#head-rendering)
26
+ - [robots.txt / sitemap.xml / manifest.webmanifest](#robotstxt--sitemapxml--manifestwebmanifest)
27
+ - [File Conventions](#file-conventions)
28
+ - [JSON-LD](#json-ld)
29
+ - [OG Image](#og-image)
30
+ - [Optional Peers](#optional-peers)
31
+ - [Type Safety](#type-safety)
32
+ - [Architecture Notes](#architecture-notes)
33
+
34
+ ## Package Positioning
35
+
36
+ `@ubean/seo` is the **SEO data and response layer**, deliberately framework-glue-free:
37
+
38
+ | Layer | Responsibility |
39
+ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
40
+ | `@ubean/seo` (this pkg) | Metadata types + merging, robots/sitemap/manifest responses, file-convention discovery, JSON-LD, OG image rendering |
41
+ | `@ubean/client` | Provides the **usable** `useSchemaOrg` (pushes through unhead) and re-exports the pure JSON-LD helpers; owns page/layout/app head layers |
42
+ | `@ubean/vue` | Page-level head declaration via `definePage({ head })`; the plugin that reads it |
43
+ | `@ubean/app` | Calls `registerSeoConventions()` in `init()` (unless `seoConventions: false`) and mounts the convention routes |
44
+ | `@unhead/vue` | The head manager itself — `useSeoMeta` here is a thin wrapper over it |
45
+
46
+ **Included:**
47
+
48
+ - `useSeoMeta` (Vue-side wrapper that degrades safely outside a setup context)
49
+ - Metadata merging and dedupe: `mergeMetadata`, `mergeSeoLayers`, `dedupeMetaTags`, `dedupeLinkTags`
50
+ - Response builders: `createRobotsResponse`, `createSitemapResponse`, `createManifestResponse`, plus `formatRobotsTxt` / `formatSitemapXml` / `escapeXml`
51
+ - Config helpers: `defineRobotsConfig`, `defineSitemapConfig`, `defineManifest`
52
+ - File conventions (`/conventions`): `registerSeoConventions`, `listSeoConventions`, `discoverSeoConventions`, `conventionKindFromPath`, `SEO_CONVENTIONS`
53
+ - JSON-LD (`/json-ld`): `defineJsonLd`, `renderJsonLdScript`, `renderJsonLdScripts`, `serializeJsonLd`, `mergeJsonLd`, `schemaOrg.*`
54
+ - OG image (`/og-image`): `ImageResponse`, `renderOgImage`, `renderArticleOgImage`, `defaultTemplate`, `articleTemplate`, `renderToImage`, font loaders
55
+
56
+ **NOT included:**
57
+
58
+ - Head state management, `useHead`, page/layout head layers and the head instance — `@unhead/vue` (wrapped by `@ubean/client`)
59
+ - The `<Head>` / `<Link>` components — `@ubean/vue` / `@ubean/client`
60
+ - Route registration and the Hono app — `@ubean/app`; this package only exposes the handlers conventions return
61
+ - Server routing, `defineHandler`, request validation — `@ubean/routes` / `@ubean/app`
62
+ - Image optimization at the component level — `@ubean/image` (OG image rendering here is a build/request-time image generator, not the `@ubean/image` pipeline)
63
+
64
+ **Runtime dependency whitelist (main entry): `@unhead/vue`, `pathe`.** The main entry imports only `./json-ld` plus `@unhead/vue`, so it is browser-safe. The `/conventions` and `/og-image` subpaths statically import `node:fs` and are therefore **deliberately not re-exported** from the main barrel — import them from their subpaths on the server. `satori` and `@resvg/resvg-js` are **optional peers** used only for PNG rendering.
65
+
66
+ ## Install
67
+
68
+ ```bash
69
+ pnpm add ubean # or: pnpm add @ubean/seo
70
+ # optional peers — only needed for PNG OG images:
71
+ pnpm add satori @resvg/resvg-js
72
+ ```
73
+
74
+ ## Quick Start
75
+
76
+ **1. Page-level SEO** — the page head field is declared in `definePage`; `useSeoMeta` is for dynamic values:
77
+
78
+ ```ts
79
+ // src/pages/index.vue (script setup)
80
+ useSeoMeta({
81
+ title: 'ubean',
82
+ description: 'Full-stack Vue meta-framework',
83
+ openGraph: { type: 'website', images: [{ url: '/og.png' }] },
84
+ twitter: { card: 'summary_large_image' }
85
+ });
86
+ ```
87
+
88
+ **2. robots.txt and sitemap.xml as files** — put them in `src/` and they are served automatically:
89
+
90
+ ```ts
91
+ // src/robots.ts
92
+ import { defineRobotsConfig } from '@ubean/seo';
93
+
94
+ export default defineRobotsConfig({
95
+ userAgent: '*',
96
+ allow: '/',
97
+ disallow: ['/admin', '/api'],
98
+ sitemap: 'https://example.com/sitemap.xml'
99
+ });
100
+ ```
101
+
102
+ ```ts
103
+ // src/sitemap.ts
104
+ import { defineSitemapConfig } from '@ubean/seo';
105
+
106
+ export default defineSitemapConfig([
107
+ { loc: '/', changefreq: 'weekly', priority: 1 },
108
+ { loc: '/about', changefreq: 'monthly', priority: 0.5 }
109
+ ]);
110
+ ```
111
+
112
+ **3. An OG image** — either as a convention file or directly in a route:
113
+
114
+ ```ts
115
+ // src/opengraph-image.ts
116
+ import { renderOgImage } from '@ubean/seo/og-image';
117
+
118
+ export default () => renderOgImage({ title: 'ubean', description: 'Full-stack Vue meta-framework', siteName: 'ubean' });
119
+ ```
120
+
121
+ ## Metadata Merging
122
+
123
+ ```ts
124
+ import { mergeMetadata, mergeSeoLayers, dedupeMetaTags, dedupeLinkTags } from '@ubean/seo';
125
+
126
+ const merged = mergeSeoLayers(globalMeta, layoutMeta, pageMeta); // page > layout > global
127
+ const meta = dedupeMetaTags(merged.meta ?? []);
128
+ const link = dedupeLinkTags(merged.link ?? []);
129
+ ```
130
+
131
+ - `mergeMetadata(...metadatas)` folds any number of layers; later layers win. `mergeSeoLayers(global?, layout?, page?)` is the fixed three-layer form.
132
+ - `dedupeMetaTags(tags: MetaTag[])` dedupes by `name` / `property` (last wins); `dedupeLinkTags(tags: LinkTag[])` by `rel` plus `hreflang` / `type` / `sizes`.
133
+ - `buildMetaTags` / `buildLinkTags` / `buildTitle` expose the individual projections; `renderHeadTags(meta, fallbackTitle?)` serializes a layer to an HTML string.
134
+
135
+ ## Head Rendering
136
+
137
+ `useSeoMeta<T extends Record<string, any>>(meta: T): T` wraps `@unhead/vue`'s `useSeoMeta` and **swallows errors outside a setup context**, so a module-level call degrades to a no-op instead of throwing. Import it from `@ubean/client` in application code — that is the same function, plus the usable `useSchemaOrg`.
138
+
139
+ ## robots.txt / sitemap.xml / manifest.webmanifest
140
+
141
+ ```ts
142
+ import { createManifestResponse, createRobotsResponse, createSitemapResponse } from '@ubean/seo';
143
+
144
+ // GET /robots.txt
145
+ return createRobotsResponse({ userAgent: '*', allow: '/', sitemap: 'https://example.com/sitemap.xml' });
146
+ // GET /sitemap.xml
147
+ return createSitemapResponse([{ loc: 'https://example.com/', lastmod: new Date(), priority: 1 }]);
148
+ // GET /manifest.webmanifest
149
+ return createManifestResponse({ name: 'ubean', short_name: 'ubean', display: 'standalone', theme_color: '#ffffff' });
150
+ ```
151
+
152
+ | Builder | Content-Type | Cache-Control |
153
+ | ------------------------ | ------------------------------------------ | ----------------------- |
154
+ | `createRobotsResponse` | `text/plain; charset=utf-8` | `public, max-age=3600` |
155
+ | `createSitemapResponse` | `application/xml; charset=utf-8` | `public, max-age=3600` |
156
+ | `createManifestResponse` | `application/manifest+json; charset=utf-8` | `public, max-age=86400` |
157
+
158
+ `formatRobotsTxt` / `formatSitemapXml` / `escapeXml` are the string-level helpers behind them. `RobotsOptions = { userAgent?, allow?, disallow?, sitemap?, crawlDelay? }` accepts a string or an array for `allow` / `disallow` / `sitemap` (an array or an array of same-user-agent groups renders multiple blocks). `SitemapUrl = { loc, lastmod?, changefreq?, priority? }`.
159
+
160
+ ## File Conventions
161
+
162
+ Drop a file into `src/` and the route exists — `@ubean/app` calls `registerSeoConventions()` during `init()` unless the app is created with `seoConventions: false`:
163
+
164
+ | File in `src/` | Route | Default export returns |
165
+ | -------------------- | --------------------------- | ---------------------------------- |
166
+ | `sitemap.ts` | `GET /sitemap.xml` | `SitemapUrl[]` or a Promise of it |
167
+ | `robots.ts` | `GET /robots.txt` | `RobotsOptions \| RobotsOptions[]` |
168
+ | `manifest.ts` | `GET /manifest.webmanifest` | `WebAppManifest` |
169
+ | `opengraph-image.ts` | `GET /opengraph-image` | `Response` |
170
+ | `icon.ts` | `GET /icon` | `Response` |
171
+ | `apple-icon.ts` | `GET /apple-icon` | `Response` |
172
+
173
+ ```ts
174
+ import { listSeoConventions, registerSeoConventions } from '@ubean/seo/conventions';
175
+
176
+ listSeoConventions({ srcDir: 'src' }); // → ['robots', 'sitemap'] without loading anything
177
+ await registerSeoConventions(app, { srcDir: 'src' });
178
+ ```
179
+
180
+ - Extensions are probed in the order `['.ts', '.js', '.mjs', '.mts', '.cjs']`; override with `extensions`, and filter with `enabled` / `disabled` (arrays of `SeoConventionKind`).
181
+ - A missing file is skipped silently, as is a module whose default export is not a function — a placeholder file is harmless.
182
+ - Conventions are mounted **after** user routes, so an explicit route in `src/routes/` always wins.
183
+ - `registerSeoConventions` registers routes on the app; `SeoConventionApp` is a structural `{ get(path, ...handlers) }` type, so this package does not depend on `@ubean/app`.
184
+ - For serverless deployments, pre-glob the files and pass them to `registerSeoConventionModules(app, modules)` to avoid filesystem reads; `conventionKindFromPath(filePath)` maps a path to its kind.
185
+
186
+ ## JSON-LD
187
+
188
+ ```ts
189
+ import { defineJsonLd, renderJsonLdScript, schemaOrg } from '@ubean/seo/json-ld';
190
+
191
+ const schema = schemaOrg.article({
192
+ headline: 'Hello ubean',
193
+ author: 'Ada',
194
+ datePublished: '2026-01-01',
195
+ image: 'https://example.com/cover.png'
196
+ });
197
+
198
+ defineJsonLd(schema); // register (client: use useSchemaOrg from @ubean/client)
199
+ renderJsonLdScript(schema); // <script type="application/ld+json">…</script>
200
+ ```
201
+
202
+ - Factories are exactly `organization({ name, url?, logo?, sameAs? })`, `website({ name, url, description? })`, `article({ headline, author, datePublished, description?, image?, publisher? })`, `breadcrumb([{ name, url }])` and `product({ name, description?, brand?, sku?, price?, priceCurrency?, availability? })`.
203
+ - `renderJsonLdScript` splits `</script>` into `<\/script>` and escapes U+2028 / U+2029, so a schema containing HTML cannot break out of the tag. `serializeJsonLd` is the shared implementation `@ubean/client` reuses.
204
+ - `defineJsonLd` / `useSchemaOrg` inside this package are the **isomorphic fallback**: they push to `globalThis.__UBEAN_HEAD__`, which nothing registers, so they cannot reach a real head on their own. In a Vue app call `useSchemaOrg` from `@ubean/client` (it goes through unhead's `useHead`).
205
+ - `mergeJsonLd` / `renderJsonLdScripts` combine and serialize several schemas.
206
+
207
+ ## OG Image
208
+
209
+ ```ts
210
+ import { ImageResponse, isOgImageSupported, renderArticleOgImage, renderOgImage } from '@ubean/seo/og-image';
211
+
212
+ if (await isOgImageSupported()) {
213
+ const response = await renderOgImage({
214
+ title: 'ubean',
215
+ description: 'Full-stack Vue meta-framework',
216
+ siteName: 'ubean',
217
+ themeColor: '#3b82f6'
218
+ });
219
+ // response.headers.get('Content-Type') === 'image/png'
220
+ }
221
+
222
+ export default () => new ImageResponse(defaultTemplate({ title: 'Hello' }), { width: 1200, height: 630 });
223
+ ```
224
+
225
+ - `OgTemplateInput = { title, description?, siteName?, logo?, themeColor?, textColor?, backgroundImage? }` — `title` is required; `OgImageOptions` adds `width` (1200) / `height` (630) / `fonts` / `svgOnly` / `resvgOptions` / `cacheControl` (`public, max-age=86400`) / `debug`.
226
+ - `renderOgImage` uses the default template; `renderArticleOgImage` adds `author` / `date` and uses the article template. `defaultTemplate` / `articleTemplate` return the Satori VDOM if you want to compose your own, and `renderToImage(node, options?)` is the low-level renderer returning `{ body, contentType }`.
227
+ - `ImageResponse` extends `Response` and renders lazily through a `ReadableStream`, so the image is produced when the body is consumed.
228
+ - Fonts: `loadDefaultFont()` fetches Inter from Google Fonts; `loadFontFromUrl(url, name, weight?, fontStyle?)` / `loadFontFromFile(path, name, weight?, fontStyle?)` load your own. `DEFAULT_FONT_URL` / `DEFAULT_FONT_NAME` are exported for reference.
229
+ - `svgOnly: true` returns SVG instead of PNG (works without `@resvg/resvg-js`).
230
+
231
+ ## Optional Peers
232
+
233
+ `satori` and `@resvg/resvg-js` are **optional** — only PNG rendering needs them:
234
+
235
+ - **Without both peers:** metadata merging, `useSeoMeta`, robots / sitemap / manifest responses, all file conventions except the image ones, and JSON-LD all work. `renderOgImage` / `renderArticleOgImage` / `renderToImage` throw when they try to render PNG.
236
+ - **`satori` only:** `svgOnly: true` works; PNG still fails.
237
+ - **Both installed:** `image/png` output.
238
+ - `isOgImageSupported(): Promise<boolean>` is the capability probe — `await import('satori')` then `await import('@resvg/resvg-js')`, returning `false` if either is missing. Gate an OG route on it (or provide an SVG/static fallback).
239
+ - `vue` is also listed as an optional peer because `useSeoMeta` needs it; without Vue, import only from `/json-ld`, `/conventions` and `/og-image`.
240
+
241
+ ## Type Safety
242
+
243
+ - Metadata types: `SeoMetadata`, `MetaTag`, `LinkTag`, `OpenGraphMeta`, `OGImage`, `TwitterMeta`.
244
+ - Response option types: `RobotsOptions`, `SitemapUrl`, `WebAppManifest`, `ManifestIcon`.
245
+ - Convention types: `SeoConventionKind`, `SeoConventionDescriptor`, `RegisterSeoConventionsOptions`, `SeoConventionApp`, `SeoConventionContext`.
246
+ - JSON-LD types: `JsonLdSchema`, `JsonLdInput`.
247
+ - OG image types: `OgImageOptions`, `OgTemplateInput`, `SatoriFont`, `SatoriNode`.
248
+ - `SeoMetadata.titleTemplate` accepts a string or a `(title) => string` function; `SeoMetadata.robots` accepts a string or `{ index?, follow? }`.
249
+
250
+ ## Architecture Notes
251
+
252
+ - **Subpath split by runtime:** the main entry is browser-safe (it imports only `@unhead/vue` and `./json-ld`). `/conventions` and `/og-image` statically import `node:fs` and are **deliberately not re-exported** from the main barrel — importing them from a client module would pull Node builtins into the browser build.
253
+ - **Isomorphic vs usable `useSchemaOrg`:** this package cannot depend on Vue/unhead internals, so its own `useSchemaOrg` is a registry-based fallback. `@ubean/client` owns the usable one and reuses `serializeJsonLd` for escaping — one escaping implementation, two call sites.
254
+ - **Frameworks glue elsewhere:** this package never imports `@ubean/app`; `SeoConventionApp` is a structural type, which keeps the SEO layer dependency-free and keeps the route mounting decision in the app factory.
255
+ - **Composition over duplication:** `mergeMetadata` is the single merge primitive; `mergeSeoLayers` is a three-argument convenience over it, and dedupe runs at merge time so consumers do not re-implement last-wins semantics.
256
+ - **Peer-gated capability:** PNG rendering degrades gracefully and is probed at runtime via `isOgImageSupported()`, so a project without the heavy image peers still gets every other SEO feature.
257
+ - **Ownership:** `@ubean/client` binds metadata to a head instance; `@ubean/app` mounts the conventions; `@ubean/seo` owns the data, the escaping and the rendering primitives.
@@ -0,0 +1,259 @@
1
+ # @ubean/seo
2
+
3
+ **[English](./README.md) | 中文**
4
+
5
+ ubean 的 SEO 工具集 —— 元数据合并、head 管理、robots / sitemap / manifest 响应、文件约定、JSON-LD 与 OG 图生成。
6
+
7
+ `@ubean/seo` 拥有**服务端与同构的 SEO 导出面**:跨层合并元数据、渲染 robots.txt / sitemap.xml / manifest.webmanifest、发现 `src/sitemap.ts` 这类约定文件、产出 JSON-LD,以及在 optional-peer 子路径之后渲染 Open Graph 图。
8
+
9
+ ```ts
10
+ import { useSeoMeta } from '@ubean/seo';
11
+
12
+ useSeoMeta({
13
+ title: 'ubean',
14
+ description: 'Full-stack Vue meta-framework',
15
+ openGraph: { type: 'website' }
16
+ });
17
+ ```
18
+
19
+ ## 目录
20
+
21
+ - [@ubean/seo](#ubeanseo)
22
+ - [目录](#目录)
23
+ - [包定位](#包定位)
24
+ - [安装](#安装)
25
+ - [快速开始](#快速开始)
26
+ - [元数据合并](#元数据合并)
27
+ - [Head 渲染](#head-渲染)
28
+ - [robots.txt / sitemap.xml / manifest.webmanifest](#robotstxt--sitemapxml--manifestwebmanifest)
29
+ - [文件约定](#文件约定)
30
+ - [JSON-LD](#json-ld)
31
+ - [OG 图](#og-图)
32
+ - [可选 peer](#可选-peer)
33
+ - [类型安全](#类型安全)
34
+ - [架构说明](#架构说明)
35
+
36
+ ## 包定位
37
+
38
+ `@ubean/seo` 是 **SEO 数据与响应层**,刻意不含框架胶水:
39
+
40
+ | 分层 | 职责 |
41
+ | -------------------- | ---------------------------------------------------------------------------------------------------------- |
42
+ | `@ubean/seo`(本包) | 元数据类型与合并、robots/sitemap/manifest 响应、文件约定发现、JSON-LD、OG 图渲染 |
43
+ | `@ubean/client` | 提供**能用的** `useSchemaOrg`(通过 unhead 推入)并 re-export 纯 JSON-LD 助手;拥有页面/布局/应用三层 head |
44
+ | `@ubean/vue` | 通过 `definePage({ head })` 声明页面级 head;读取它的插件 |
45
+ | `@ubean/app` | 在 `init()` 中调用 `registerSeoConventions()`(除非 `seoConventions: false`)并挂载约定路由 |
46
+ | `@unhead/vue` | head 管理器本体 —— 这里的 `useSeoMeta` 只是它的薄包装 |
47
+
48
+ **包含:**
49
+
50
+ - `useSeoMeta`(Vue 侧包装,在 setup 上下文之外安全降级)
51
+ - 元数据合并与去重:`mergeMetadata`、`mergeSeoLayers`、`dedupeMetaTags`、`dedupeLinkTags`
52
+ - 响应构造器:`createRobotsResponse`、`createSitemapResponse`、`createManifestResponse`,以及 `formatRobotsTxt` / `formatSitemapXml` / `escapeXml`
53
+ - 配置助手:`defineRobotsConfig`、`defineSitemapConfig`、`defineManifest`
54
+ - 文件约定(`/conventions`):`registerSeoConventions`、`listSeoConventions`、`discoverSeoConventions`、`conventionKindFromPath`、`SEO_CONVENTIONS`
55
+ - JSON-LD(`/json-ld`):`defineJsonLd`、`renderJsonLdScript`、`renderJsonLdScripts`、`serializeJsonLd`、`mergeJsonLd`、`schemaOrg.*`
56
+ - OG 图(`/og-image`):`ImageResponse`、`renderOgImage`、`renderArticleOgImage`、`defaultTemplate`、`articleTemplate`、`renderToImage`、字体加载器
57
+
58
+ **不包含:**
59
+
60
+ - head 状态管理、`useHead`、页面/布局 head 层与 head 实例 —— `@unhead/vue`(由 `@ubean/client` 包装)
61
+ - `<Head>` / `<Link>` 组件 —— `@ubean/vue` / `@ubean/client`
62
+ - 路由注册与 Hono 应用 —— `@ubean/app`;本包只暴露约定返回的 handler
63
+ - 服务端路由、`defineHandler`、请求校验 —— `@ubean/routes` / `@ubean/app`
64
+ - 组件级图片优化 —— `@ubean/image`(这里的 OG 图渲染是构建/请求期图像生成器,不是 `@ubean/image` 管线)
65
+
66
+ **运行时依赖白名单(主入口):`@unhead/vue`、`pathe`。** 主入口只导入 `./json-ld` 与 `@unhead/vue`,因此浏览器安全。`/conventions` 与 `/og-image` 子路径静态导入 `node:fs`,因此**刻意不从主 barrel re-export** —— 在服务端从各自子路径导入。`satori` 与 `@resvg/resvg-js` 是 **optional peer**,仅 PNG 渲染需要。
67
+
68
+ ## 安装
69
+
70
+ ```bash
71
+ pnpm add ubean # 或:pnpm add @ubean/seo
72
+ # optional peers —— 仅 PNG OG 图需要:
73
+ pnpm add satori @resvg/resvg-js
74
+ ```
75
+
76
+ ## 快速开始
77
+
78
+ **1. 页面级 SEO** —— 静态 head 由 `definePage` 声明;`useSeoMeta` 用于动态值:
79
+
80
+ ```ts
81
+ // src/pages/index.vue (script setup)
82
+ useSeoMeta({
83
+ title: 'ubean',
84
+ description: 'Full-stack Vue meta-framework',
85
+ openGraph: { type: 'website', images: [{ url: '/og.png' }] },
86
+ twitter: { card: 'summary_large_image' }
87
+ });
88
+ ```
89
+
90
+ **2. 把 robots.txt 与 sitemap.xml 当文件写** —— 放进 `src/` 即自动提供:
91
+
92
+ ```ts
93
+ // src/robots.ts
94
+ import { defineRobotsConfig } from '@ubean/seo';
95
+
96
+ export default defineRobotsConfig({
97
+ userAgent: '*',
98
+ allow: '/',
99
+ disallow: ['/admin', '/api'],
100
+ sitemap: 'https://example.com/sitemap.xml'
101
+ });
102
+ ```
103
+
104
+ ```ts
105
+ // src/sitemap.ts
106
+ import { defineSitemapConfig } from '@ubean/seo';
107
+
108
+ export default defineSitemapConfig([
109
+ { loc: '/', changefreq: 'weekly', priority: 1 },
110
+ { loc: '/about', changefreq: 'monthly', priority: 0.5 }
111
+ ]);
112
+ ```
113
+
114
+ **3. 一张 OG 图** —— 既可以写成约定文件,也可以直接在路由里调用:
115
+
116
+ ```ts
117
+ // src/opengraph-image.ts
118
+ import { renderOgImage } from '@ubean/seo/og-image';
119
+
120
+ export default () => renderOgImage({ title: 'ubean', description: 'Full-stack Vue meta-framework', siteName: 'ubean' });
121
+ ```
122
+
123
+ ## 元数据合并
124
+
125
+ ```ts
126
+ import { mergeMetadata, mergeSeoLayers, dedupeMetaTags, dedupeLinkTags } from '@ubean/seo';
127
+
128
+ const merged = mergeSeoLayers(globalMeta, layoutMeta, pageMeta); // page > layout > global
129
+ const meta = dedupeMetaTags(merged.meta ?? []);
130
+ const link = dedupeLinkTags(merged.link ?? []);
131
+ ```
132
+
133
+ - `mergeMetadata(...metadatas)` 折叠任意层数,后者覆盖前者。`mergeSeoLayers(global?, layout?, page?)` 是固定的三层形态。
134
+ - `dedupeMetaTags(tags: MetaTag[])` 按 `name` / `property` 去重(后者胜);`dedupeLinkTags(tags: LinkTag[])` 按 `rel` 加 `hreflang` / `type` / `sizes` 去重。
135
+ - `buildMetaTags` / `buildLinkTags` / `buildTitle` 暴露各自的投影;`renderHeadTags(meta, fallbackTitle?)` 把某一层序列化为 HTML 字符串。
136
+
137
+ ## Head 渲染
138
+
139
+ `useSeoMeta<T extends Record<string, any>>(meta: T): T` 包装 `@unhead/vue` 的 `useSeoMeta`,并**吞掉 setup 上下文之外的错误**,因此模块级调用会降级为 no-op 而不是抛错。应用代码请从 `@ubean/client` 导入它 —— 那是同一个函数,外加能用的 `useSchemaOrg`。
140
+
141
+ ## robots.txt / sitemap.xml / manifest.webmanifest
142
+
143
+ ```ts
144
+ import { createManifestResponse, createRobotsResponse, createSitemapResponse } from '@ubean/seo';
145
+
146
+ // GET /robots.txt
147
+ return createRobotsResponse({ userAgent: '*', allow: '/', sitemap: 'https://example.com/sitemap.xml' });
148
+ // GET /sitemap.xml
149
+ return createSitemapResponse([{ loc: 'https://example.com/', lastmod: new Date(), priority: 1 }]);
150
+ // GET /manifest.webmanifest
151
+ return createManifestResponse({ name: 'ubean', short_name: 'ubean', display: 'standalone', theme_color: '#ffffff' });
152
+ ```
153
+
154
+ | 构造器 | Content-Type | Cache-Control |
155
+ | ------------------------ | ------------------------------------------ | ----------------------- |
156
+ | `createRobotsResponse` | `text/plain; charset=utf-8` | `public, max-age=3600` |
157
+ | `createSitemapResponse` | `application/xml; charset=utf-8` | `public, max-age=3600` |
158
+ | `createManifestResponse` | `application/manifest+json; charset=utf-8` | `public, max-age=86400` |
159
+
160
+ `formatRobotsTxt` / `formatSitemapXml` / `escapeXml` 是它们背后的字符串级助手。`RobotsOptions = { userAgent?, allow?, disallow?, sitemap?, crawlDelay? }` 中 `allow` / `disallow` / `sitemap` 接受字符串或数组(传入数组或同 user-agent 的组数组会渲染成多个区块)。`SitemapUrl = { loc, lastmod?, changefreq?, priority? }`。
161
+
162
+ ## 文件约定
163
+
164
+ 把文件丢进 `src/` 路由就有了 —— 除非创建应用时传 `seoConventions: false`,`@ubean/app` 会在 `init()` 期间调用 `registerSeoConventions()`:
165
+
166
+ | `src/` 中的文件 | 路由 | default export 返回 |
167
+ | -------------------- | --------------------------- | ---------------------------------- |
168
+ | `sitemap.ts` | `GET /sitemap.xml` | `SitemapUrl[]` 或其 Promise |
169
+ | `robots.ts` | `GET /robots.txt` | `RobotsOptions \| RobotsOptions[]` |
170
+ | `manifest.ts` | `GET /manifest.webmanifest` | `WebAppManifest` |
171
+ | `opengraph-image.ts` | `GET /opengraph-image` | `Response` |
172
+ | `icon.ts` | `GET /icon` | `Response` |
173
+ | `apple-icon.ts` | `GET /apple-icon` | `Response` |
174
+
175
+ ```ts
176
+ import { listSeoConventions, registerSeoConventions } from '@ubean/seo/conventions';
177
+
178
+ listSeoConventions({ srcDir: 'src' }); // → ['robots', 'sitemap'],不加载任何内容
179
+ await registerSeoConventions(app, { srcDir: 'src' });
180
+ ```
181
+
182
+ - 扩展名按 `['.ts', '.js', '.mjs', '.mts', '.cjs']` 顺序探测;可用 `extensions` 覆盖,用 `enabled` / `disabled`(`SeoConventionKind` 数组)过滤。
183
+ - 文件缺失会被静默跳过;default export 不是函数的模块同样被跳过 —— 占位文件无害。
184
+ - 约定在用户路由**之后**挂载,因此 `src/routes/` 里的显式路由总是优先。
185
+ - `registerSeoConventions` 在 app 上注册路由;`SeoConventionApp` 是结构化的 `{ get(path, ...handlers) }` 类型,因此本包不依赖 `@ubean/app`。
186
+ - serverless 部署可预先 glob 文件并传给 `registerSeoConventionModules(app, modules)`,从而避免文件系统读取;`conventionKindFromPath(filePath)` 把路径映射到其 kind。
187
+
188
+ ## JSON-LD
189
+
190
+ ```ts
191
+ import { defineJsonLd, renderJsonLdScript, schemaOrg } from '@ubean/seo/json-ld';
192
+
193
+ const schema = schemaOrg.article({
194
+ headline: 'Hello ubean',
195
+ author: 'Ada',
196
+ datePublished: '2026-01-01',
197
+ image: 'https://example.com/cover.png'
198
+ });
199
+
200
+ defineJsonLd(schema); // 注册(客户端请用 @ubean/client 的 useSchemaOrg)
201
+ renderJsonLdScript(schema); // <script type="application/ld+json">…</script>
202
+ ```
203
+
204
+ - 工厂恰好是 `organization({ name, url?, logo?, sameAs? })`、`website({ name, url, description? })`、`article({ headline, author, datePublished, description?, image?, publisher? })`、`breadcrumb([{ name, url }])` 与 `product({ name, description?, brand?, sku?, price?, priceCurrency?, availability? })`。
205
+ - `renderJsonLdScript` 把 `</script>` 拆成 `<\/script>` 并转义 U+2028 / U+2029,因此含 HTML 的 schema 无法逃出标签。`serializeJsonLd` 是 `@ubean/client` 复用的同一份实现。
206
+ - 本包内的 `defineJsonLd` / `useSchemaOrg` 是**同构降级版**:它们把数据推给 `globalThis.__UBEAN_HEAD__`,而没有人注册它,因此单独用无法触达真正的 head。Vue 应用中请调用 `@ubean/client` 的 `useSchemaOrg`(它走 unhead 的 `useHead`)。
207
+ - `mergeJsonLd` / `renderJsonLdScripts` 用于合并与序列化多个 schema。
208
+
209
+ ## OG 图
210
+
211
+ ```ts
212
+ import { ImageResponse, isOgImageSupported, renderArticleOgImage, renderOgImage } from '@ubean/seo/og-image';
213
+
214
+ if (await isOgImageSupported()) {
215
+ const response = await renderOgImage({
216
+ title: 'ubean',
217
+ description: 'Full-stack Vue meta-framework',
218
+ siteName: 'ubean',
219
+ themeColor: '#3b82f6'
220
+ });
221
+ // response.headers.get('Content-Type') === 'image/png'
222
+ }
223
+
224
+ export default () => new ImageResponse(defaultTemplate({ title: 'Hello' }), { width: 1200, height: 630 });
225
+ ```
226
+
227
+ - `OgTemplateInput = { title, description?, siteName?, logo?, themeColor?, textColor?, backgroundImage? }` —— `title` 必填;`OgImageOptions` 额外提供 `width`(1200)/ `height`(630)/ `fonts` / `svgOnly` / `resvgOptions` / `cacheControl`(`public, max-age=86400`)/ `debug`。
228
+ - `renderOgImage` 使用默认模板;`renderArticleOgImage` 额外接受 `author` / `date` 并使用文章模板。`defaultTemplate` / `articleTemplate` 返回 Satori VDOM,便于自行组合;`renderToImage(node, options?)` 是底层渲染器,返回 `{ body, contentType }`。
229
+ - `ImageResponse` 继承 `Response`,通过 `ReadableStream` 惰性渲染,因此图像在 body 被消费时才生成。
230
+ - 字体:`loadDefaultFont()` 从 Google Fonts 拉取 Inter;`loadFontFromUrl(url, name, weight?, fontStyle?)` / `loadFontFromFile(path, name, weight?, fontStyle?)` 加载自有字体。`DEFAULT_FONT_URL` / `DEFAULT_FONT_NAME` 已导出备查。
231
+ - `svgOnly: true` 返回 SVG 而非 PNG(无需 `@resvg/resvg-js` 即可工作)。
232
+
233
+ ## 可选 peer
234
+
235
+ `satori` 与 `@resvg/resvg-js` 是**可选**的 —— 只有 PNG 渲染需要它们:
236
+
237
+ - **两个都没装:** 元数据合并、`useSeoMeta`、robots / sitemap / manifest 响应、除图像类之外的全部文件约定、JSON-LD 都能正常工作。`renderOgImage` / `renderArticleOgImage` / `renderToImage` 在尝试渲染 PNG 时抛错。
238
+ - **只装 `satori`:** `svgOnly: true` 可用;PNG 仍然失败。
239
+ - **两个都装:** 产出 `image/png`。
240
+ - `isOgImageSupported(): Promise<boolean>` 是能力探测 —— 先 `await import('satori')` 再 `await import('@resvg/resvg-js')`,任一缺失即返回 `false`。用它把 OG 路由护起来(或提供 SVG/静态兜底)。
241
+ - `vue` 也被列为 optional peer,因为 `useSeoMeta` 需要它;没有 Vue 时只从 `/json-ld`、`/conventions`、`/og-image` 导入。
242
+
243
+ ## 类型安全
244
+
245
+ - 元数据类型:`SeoMetadata`、`MetaTag`、`LinkTag`、`OpenGraphMeta`、`OGImage`、`TwitterMeta`。
246
+ - 响应选项类型:`RobotsOptions`、`SitemapUrl`、`WebAppManifest`、`ManifestIcon`。
247
+ - 约定类型:`SeoConventionKind`、`SeoConventionDescriptor`、`RegisterSeoConventionsOptions`、`SeoConventionApp`、`SeoConventionContext`。
248
+ - JSON-LD 类型:`JsonLdSchema`、`JsonLdInput`。
249
+ - OG 图类型:`OgImageOptions`、`OgTemplateInput`、`SatoriFont`、`SatoriNode`。
250
+ - `SeoMetadata.titleTemplate` 接受字符串或 `(title) => string` 函数;`SeoMetadata.robots` 接受字符串或 `{ index?, follow? }`。
251
+
252
+ ## 架构说明
253
+
254
+ - **按运行时切分子路径:** 主入口浏览器安全(只导入 `@unhead/vue` 与 `./json-ld`)。`/conventions` 与 `/og-image` 静态导入 `node:fs`,因此**刻意不从主 barrel re-export** —— 从客户端模块导入它们会把 Node 内置模块拉进浏览器构建。
255
+ - **同构版与可用版 `useSchemaOrg`:** 本包不能依赖 Vue/unhead 内部,因此自带的 `useSchemaOrg` 是基于注册表的降级实现。可用版本由 `@ubean/client` 拥有,并复用 `serializeJsonLd` 完成转义 —— 一份转义实现,两个调用点。
256
+ - **框架胶水不在本包:** 它从不导入 `@ubean/app`;`SeoConventionApp` 是结构化类型,这既让 SEO 层零依赖,也让路由挂载决策留在应用工厂里。
257
+ - **组合优于重复:** `mergeMetadata` 是唯一的合并原语;`mergeSeoLayers` 是其三参数便利包装,去重在合并时完成,消费者无需重复实现 last-wins 语义。
258
+ - **按 peer 开闸的能力:** PNG 渲染优雅降级,并在运行时通过 `isOgImageSupported()` 探测,因此没装重依赖的项目仍能获得其余全部 SEO 能力。
259
+ - **归属:** `@ubean/client` 把元数据绑定到 head 实例;`@ubean/app` 挂载约定路由;`@ubean/seo` 拥有数据、转义与渲染原语。
@@ -82,4 +82,5 @@ export declare function registerSeoConventions(app: SeoConventionApp, options: R
82
82
  * 检测项目使用了哪些约定(例如 CLI 信息展示)。
83
83
  */
84
84
  export declare function listSeoConventions(options: Pick<RegisterSeoConventionsOptions, 'srcDir' | 'extensions'>): SeoConventionKind[];
85
- //#endregion
85
+ //#endregion
86
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ubean/seo",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "SEO utilities for ubean (useSeoMeta, robots, sitemap, manifest, file conventions, JSON-LD, OG image)",
5
5
  "files": [
6
6
  "dist"
@@ -28,13 +28,13 @@
28
28
  }
29
29
  },
30
30
  "dependencies": {
31
- "@unhead/vue": "^3.4.2",
31
+ "@unhead/vue": "^3.4.3",
32
32
  "pathe": "^2.0.3"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@types/node": "^26.6.5",
36
36
  "typescript": "npm:typescript-native-bridge@latest",
37
- "vite-plus": "1.1.0"
37
+ "vite-plus": "1.3.0"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@resvg/resvg-js": "^2.6.0",