@kitr/seo 0.3.1 → 0.3.2
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/index.d.ts +12 -5
- package/dist/index.js +26 -2
- package/docs/api.md +12 -5
- package/docs/index.md +33 -16
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -18,10 +18,15 @@ export interface SeoConfig {
|
|
|
18
18
|
site?: SeoSite;
|
|
19
19
|
/** Default rendering for routes that set no `ssr` of their own (the router's `defaultSsr`). `ssrRoute()` overrides it per route. */
|
|
20
20
|
ssr?: boolean | 'data-only';
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* Routes to prerender at build time; `crawlLinks` follows links from them. A `static` web platform
|
|
23
|
+
* prerenders everything. A listed route that fails to render fails the build; a crawled link only
|
|
24
|
+
* warns, unless `failOnError` is `true`.
|
|
25
|
+
*/
|
|
22
26
|
prerender?: string[] | {
|
|
23
27
|
routes: string[];
|
|
24
28
|
crawlLinks?: boolean;
|
|
29
|
+
failOnError?: boolean;
|
|
25
30
|
};
|
|
26
31
|
/** Rules for `robotsTxt()`, which the build writes to `robots.txt` unless `public/robots.txt` exists. `false` writes none. */
|
|
27
32
|
robots?: false | {
|
|
@@ -43,13 +48,13 @@ declare module '@kitr/core' {
|
|
|
43
48
|
export declare const defineConfig: <const C extends SeoConfig>(config: C) => C;
|
|
44
49
|
/** What one page passes to `seo()`. Every field is optional; `site` supplies the defaults. */
|
|
45
50
|
export interface SeoInput {
|
|
46
|
-
/** The page title, without the site name. */
|
|
51
|
+
/** The page title, without the site name. Default: `title` from the route's loader data. */
|
|
47
52
|
title?: string;
|
|
48
|
-
/** The page summary for search results and share cards. */
|
|
53
|
+
/** The page summary for search results and share cards. Default: `description` from the route's loader data. */
|
|
49
54
|
description?: string;
|
|
50
55
|
/** Share image for this page; replaces `site.image`. Use an absolute URL. */
|
|
51
56
|
image?: string;
|
|
52
|
-
/** The page's canonical URL
|
|
57
|
+
/** The page's canonical URL. Default: `site.url` plus the matched path from the `head` argument, else `site.url`. */
|
|
53
58
|
url?: string;
|
|
54
59
|
/** `og:type`. Default `website`. */
|
|
55
60
|
type?: 'website' | 'article' | 'product';
|
|
@@ -70,7 +75,9 @@ export type SeoContext = object;
|
|
|
70
75
|
* Meta tags for a route's `head()`: `head: (ctx) => seo({ title, description }, ctx)`.
|
|
71
76
|
* Covers title, description, Open Graph, Twitter cards, canonical and robots. Pass the `head`
|
|
72
77
|
* argument (or the app) as the second argument so the site name, image and origin from
|
|
73
|
-
* `config/seo.ts` apply; without it only `input.site` does.
|
|
78
|
+
* `config/seo.ts` apply; without it only `input.site` does. From the `head` argument it also
|
|
79
|
+
* takes the canonical URL (`site.url` plus the matched path) and, when the loader returned
|
|
80
|
+
* them, `title` and `description`: `head: (ctx) => seo({}, ctx)`.
|
|
74
81
|
*/
|
|
75
82
|
export declare function seo(input: SeoInput, context?: SeoContext): {
|
|
76
83
|
meta: Record<string, string>[];
|
package/dist/index.js
CHANGED
|
@@ -19,18 +19,42 @@ function appOf(context) {
|
|
|
19
19
|
};
|
|
20
20
|
return candidates(context).find(isApp);
|
|
21
21
|
}
|
|
22
|
+
/** The matched path from a `head` argument (`match.pathname`), or a `location`'s. */
|
|
23
|
+
function pathOf(context) {
|
|
24
|
+
const v = context;
|
|
25
|
+
const path = v?.match?.pathname ?? v?.location?.pathname;
|
|
26
|
+
return typeof path === "string" && path.startsWith("/") ? path : void 0;
|
|
27
|
+
}
|
|
28
|
+
/** `title` and `description` from a `head` argument's `loaderData`, when the loader returned them. */
|
|
29
|
+
function fromLoader(context) {
|
|
30
|
+
const data = context?.loaderData;
|
|
31
|
+
if (!data || typeof data !== "object") return {};
|
|
32
|
+
return {
|
|
33
|
+
...typeof data.title === "string" ? { title: data.title } : {},
|
|
34
|
+
...typeof data.description === "string" ? { description: data.description } : {}
|
|
35
|
+
};
|
|
36
|
+
}
|
|
22
37
|
/**
|
|
23
38
|
* Meta tags for a route's `head()`: `head: (ctx) => seo({ title, description }, ctx)`.
|
|
24
39
|
* Covers title, description, Open Graph, Twitter cards, canonical and robots. Pass the `head`
|
|
25
40
|
* argument (or the app) as the second argument so the site name, image and origin from
|
|
26
|
-
* `config/seo.ts` apply; without it only `input.site` does.
|
|
41
|
+
* `config/seo.ts` apply; without it only `input.site` does. From the `head` argument it also
|
|
42
|
+
* takes the canonical URL (`site.url` plus the matched path) and, when the loader returned
|
|
43
|
+
* them, `title` and `description`: `head: (ctx) => seo({}, ctx)`.
|
|
27
44
|
*/
|
|
28
45
|
function seo(input, context) {
|
|
29
46
|
const configured = appOf(context)?.config.get("seo", {});
|
|
30
47
|
const site = input.site ?? configured?.site ?? {};
|
|
48
|
+
const loaded = fromLoader(context);
|
|
49
|
+
input = {
|
|
50
|
+
...input,
|
|
51
|
+
title: input.title ?? loaded.title,
|
|
52
|
+
description: input.description ?? loaded.description
|
|
53
|
+
};
|
|
31
54
|
const title = input.title ? site.name && input.title !== site.name ? `${input.title} · ${site.name}` : input.title : site.name;
|
|
32
55
|
const image = input.image ?? site.image;
|
|
33
|
-
const
|
|
56
|
+
const path = pathOf(context);
|
|
57
|
+
const url = input.url ?? (site.url && path ? `${site.url.replace(/\/+$/, "")}${path}` : site.url);
|
|
34
58
|
const meta = [];
|
|
35
59
|
if (title) meta.push({ title }, {
|
|
36
60
|
property: "og:title",
|
package/docs/api.md
CHANGED
|
@@ -69,7 +69,9 @@ function seo(input: SeoInput, context?: SeoContext): {
|
|
|
69
69
|
Meta tags for a route's `head()`: `head: (ctx) => seo({ title, description }, ctx)`.
|
|
70
70
|
Covers title, description, Open Graph, Twitter cards, canonical and robots. Pass the `head`
|
|
71
71
|
argument (or the app) as the second argument so the site name, image and origin from
|
|
72
|
-
`config/seo.ts` apply; without it only `input.site` does.
|
|
72
|
+
`config/seo.ts` apply; without it only `input.site` does. From the `head` argument it also
|
|
73
|
+
takes the canonical URL (`site.url` plus the matched path) and, when the loader returned
|
|
74
|
+
them, `title` and `description`: `head: (ctx) => seo({}, ctx)`.
|
|
73
75
|
|
|
74
76
|
### ssrRoute
|
|
75
77
|
|
|
@@ -104,10 +106,15 @@ interface SeoConfig {
|
|
|
104
106
|
site?: SeoSite;
|
|
105
107
|
/** Default rendering for routes that set no `ssr` of their own (the router's `defaultSsr`). `ssrRoute()` overrides it per route. */
|
|
106
108
|
ssr?: boolean | 'data-only';
|
|
107
|
-
/**
|
|
109
|
+
/**
|
|
110
|
+
* Routes to prerender at build time; `crawlLinks` follows links from them. A `static` web platform
|
|
111
|
+
* prerenders everything. A listed route that fails to render fails the build; a crawled link only
|
|
112
|
+
* warns, unless `failOnError` is `true`.
|
|
113
|
+
*/
|
|
108
114
|
prerender?: string[] | {
|
|
109
115
|
routes: string[];
|
|
110
116
|
crawlLinks?: boolean;
|
|
117
|
+
failOnError?: boolean;
|
|
111
118
|
};
|
|
112
119
|
/** Rules for `robotsTxt()`, which the build writes to `robots.txt` unless `public/robots.txt` exists. `false` writes none. */
|
|
113
120
|
robots?: false | {
|
|
@@ -124,13 +131,13 @@ Options for `config/seo.ts`. The build reads `prerender`, `site.url` and `robots
|
|
|
124
131
|
|
|
125
132
|
```ts
|
|
126
133
|
interface SeoInput {
|
|
127
|
-
/** The page title, without the site name. */
|
|
134
|
+
/** The page title, without the site name. Default: `title` from the route's loader data. */
|
|
128
135
|
title?: string;
|
|
129
|
-
/** The page summary for search results and share cards. */
|
|
136
|
+
/** The page summary for search results and share cards. Default: `description` from the route's loader data. */
|
|
130
137
|
description?: string;
|
|
131
138
|
/** Share image for this page; replaces `site.image`. Use an absolute URL. */
|
|
132
139
|
image?: string;
|
|
133
|
-
/** The page's canonical URL
|
|
140
|
+
/** The page's canonical URL. Default: `site.url` plus the matched path from the `head` argument, else `site.url`. */
|
|
134
141
|
url?: string;
|
|
135
142
|
/** `og:type`. Default `website`. */
|
|
136
143
|
type?: 'website' | 'article' | 'product';
|
package/docs/index.md
CHANGED
|
@@ -88,6 +88,7 @@ export default defineConfig({
|
|
|
88
88
|
| `prerender` | `[]` | Routes the build renders to HTML files. An array of paths, or `{ routes, crawlLinks }`. |
|
|
89
89
|
| `prerender.routes` | `[]` | The paths to prerender, such as `['/', '/pricing']`. |
|
|
90
90
|
| `prerender.crawlLinks` | `false` | Also prerender every page linked from those routes. |
|
|
91
|
+
| `prerender.failOnError` | `false` | Fail the build when a crawled page fails to render, not only a listed one. |
|
|
91
92
|
| `robots` | `{}` | Rules for the `robots.txt` the build writes (see [Write robots.txt](#write-robotstxt)). `false` writes none. |
|
|
92
93
|
| `robots.allow` | `['/']` | `Allow:` lines. |
|
|
93
94
|
| `robots.disallow` | `[]` | `Disallow:` lines. |
|
|
@@ -137,24 +138,17 @@ View the page source of `/pricing`. The root route's `<HeadContent />` renders t
|
|
|
137
138
|
|
|
138
139
|
## Use your site defaults on every page
|
|
139
140
|
|
|
140
|
-
`seo()` takes the site name, share image, handle and locale from `site`, so every title gets the site name once you pass `ctx`.
|
|
141
|
+
`seo()` takes the site name, share image, handle and locale from `site`, so every title gets the site name once you pass `ctx`. It also builds the page's URL from `site.url` and the matched path in `ctx`, so a page needs only its title and description:
|
|
141
142
|
|
|
142
143
|
```ts
|
|
143
144
|
// app/routes.ts
|
|
144
145
|
import { seo } from '@kitr/seo'
|
|
145
146
|
|
|
146
147
|
r.page('/pricing', {
|
|
147
|
-
head: (ctx) =>
|
|
148
|
-
seo(
|
|
149
|
-
{
|
|
150
|
-
title: 'Pricing',
|
|
151
|
-
description: 'Plans for every team size.',
|
|
152
|
-
url: 'https://acme.com/pricing',
|
|
153
|
-
},
|
|
154
|
-
ctx,
|
|
155
|
-
),
|
|
148
|
+
head: (ctx) => seo({ title: 'Pricing', description: 'Plans for every team size.' }, ctx),
|
|
156
149
|
lazy: () => import('./pages/pricing'),
|
|
157
150
|
})
|
|
151
|
+
// => canonical and og:url: https://acme.com/pricing
|
|
158
152
|
```
|
|
159
153
|
|
|
160
154
|
With the config from [Configure](#configure), the title becomes `Pricing · Acme`, and `og:site_name`, `og:locale`, `og:url`, `og:image`, `twitter:site` and a canonical link are filled in. A `site` passed to `seo()` replaces the config's, for a page that belongs to another brand. How the values combine:
|
|
@@ -162,15 +156,33 @@ With the config from [Configure](#configure), the title becomes `Pricing · Acme
|
|
|
162
156
|
| Tag | Comes from |
|
|
163
157
|
| --- | --- |
|
|
164
158
|
| `<title>` | `title · site.name`; `title` alone when it equals `site.name`; `site.name` when there is no `title`. |
|
|
159
|
+
| `title`, `description` | The options, else `title` and `description` from the loader's data. |
|
|
165
160
|
| `og:title`, `twitter:title` | `title` without the site name. |
|
|
166
161
|
| `description`, `og:description`, `twitter:description` | `description`, only when set. |
|
|
167
|
-
| `og:url`, canonical link | `url`, else `site.url`. |
|
|
162
|
+
| `og:url`, canonical link | `url`, else `site.url` plus the matched path from `ctx`, else `site.url`. |
|
|
168
163
|
| `og:image`, `twitter:image` | `image`, else `site.image`. |
|
|
169
164
|
| `twitter:card` | `summary_large_image` when there is an image, else `summary`. |
|
|
170
165
|
| `og:type` | `type`, default `'website'`. |
|
|
171
166
|
|
|
172
|
-
> [!WARNING] Pass
|
|
173
|
-
> Without
|
|
167
|
+
> [!WARNING] Pass ctx on every page
|
|
168
|
+
> Without `ctx` (or `url`), `og:url` and the canonical link fall back to `site.url`, so the page tells search engines it is a copy of your home page and they may drop it.
|
|
169
|
+
|
|
170
|
+
When the loader returns `title` and `description`, `seo()` reads them from `ctx.loaderData`, so a page with data needs no options at all:
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
// app/routes.ts
|
|
174
|
+
import { seo } from '@kitr/seo'
|
|
175
|
+
import { getDoc } from '#app/content/docs'
|
|
176
|
+
|
|
177
|
+
r.page('/docs/$slug', {
|
|
178
|
+
loader: ({ params }) => getDoc(params.slug), // => { title: 'Agents', description: '...', html: '...' }
|
|
179
|
+
head: (ctx) => seo({}, ctx),
|
|
180
|
+
lazy: () => import('./pages/docs/slug'),
|
|
181
|
+
})
|
|
182
|
+
// => <title>Agents · Acme</title>, canonical https://acme.com/docs/agents
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Options you pass win over the loader's values and over the derived URL.
|
|
174
186
|
|
|
175
187
|
## Set defaults in the root route
|
|
176
188
|
|
|
@@ -259,9 +271,7 @@ r.page('/blog/$slug', {
|
|
|
259
271
|
head: (ctx) =>
|
|
260
272
|
seo(
|
|
261
273
|
{
|
|
262
|
-
title: ctx.loaderData?.title,
|
|
263
274
|
description: ctx.loaderData?.excerpt,
|
|
264
|
-
url: `https://acme.com/blog/${ctx.params.slug}`,
|
|
265
275
|
type: 'article',
|
|
266
276
|
keywords: ctx.loaderData?.tags,
|
|
267
277
|
},
|
|
@@ -283,7 +293,7 @@ export default function PostPage() {
|
|
|
283
293
|
}
|
|
284
294
|
```
|
|
285
295
|
|
|
286
|
-
`head` runs after the loader, on the server for the first request and on the client after each navigation. Search engines ignore `keywords`; it only helps your own site search.
|
|
296
|
+
The title comes from the post's `title` and the URL from the path. `head` runs after the loader, on the server for the first request and on the client after each navigation. Search engines ignore `keywords`; it only helps your own site search.
|
|
287
297
|
|
|
288
298
|
## Keep a page out of search results
|
|
289
299
|
|
|
@@ -380,6 +390,13 @@ What the build does with `prerender` in each web mode:
|
|
|
380
390
|
> [!WARNING] No computed lists
|
|
381
391
|
> `prerender: posts.map(...)` is not seen. Write each path as a string, or list a page that links to all of them with `crawlLinks: true`.
|
|
382
392
|
|
|
393
|
+
A page that fails to render prints one line and stays out of the sitemap. A listed route fails the build; a crawled link only warns, unless `failOnError: true`:
|
|
394
|
+
|
|
395
|
+
```sh
|
|
396
|
+
kitr build site
|
|
397
|
+
# [prerender] 404 at /blog/draft, linked from /blog
|
|
398
|
+
```
|
|
399
|
+
|
|
383
400
|
## Generate a sitemap
|
|
384
401
|
|
|
385
402
|
Set `site.url` to a string literal, as in the config above, and the build turns on [TanStack Start's sitemap](https://tanstack.com/start/latest/docs/framework/react/guide/static-prerendering) with that host. It writes `sitemap.xml` from the pages it prerenders, so prerender (or crawl to) every page you want listed. `robotsTxt()` points crawlers at `<site.url>/sitemap.xml`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kitr/seo",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "@kitr/seo: meta and Open Graph tags per route, prerender list, sitemap and robots, cache headers. Works for SSR, static sites, SPAs and PWAs.",
|
|
5
5
|
"kitr": {
|
|
6
6
|
"name": "seo",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"docs"
|
|
42
42
|
],
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@kitr/core": "0.3.
|
|
44
|
+
"@kitr/core": "0.3.2"
|
|
45
45
|
},
|
|
46
46
|
"peerDependencies": {},
|
|
47
47
|
"peerDependenciesMeta": {},
|