@kitr/seo 0.3.0 → 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 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
- /** Routes to prerender at build time; `crawlLinks` follows links from them. A `static` web platform prerenders everything. */
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; falls back to `site.url`, so pass it on every page. */
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 url = input.url ?? site.url;
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
- /** Routes to prerender at build time; `crawlLinks` follows links from them. A `static` web platform prerenders everything. */
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; falls back to `site.url`, so pass it on every page. */
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`. Give each page its own URL:
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 url on every page
173
- > Without it, `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.
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.0",
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.0"
44
+ "@kitr/core": "0.3.2"
45
45
  },
46
46
  "peerDependencies": {},
47
47
  "peerDependenciesMeta": {},