@kitr/seo 0.0.0-stage → 0.2.1
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/LICENSE +21 -0
- package/README.md +12 -2
- package/dist/index.d.ts +117 -0
- package/dist/index.js +151 -0
- package/dist/kitr.d.ts +11 -0
- package/dist/kitr.js +16 -0
- package/docs/api.md +183 -0
- package/docs/index.md +541 -0
- package/docs/overview.md +71 -0
- package/package.json +58 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Platon Podkopov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @kitr/seo
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**SEO.** Give every page a title, a share preview and a sitemap entry so search engines and chat apps show it well.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
pnpm kitr add seo
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
`kitr add` installs the package and wires it into your app: the provider in `kitrrc.ts`, its config file and its `AGENTS.md` section. Import it from `@kitr/seo`.
|
|
10
|
+
|
|
11
|
+
Read the guide at https://kitr.dev/packages/seo.
|
|
12
|
+
|
|
13
|
+
Part of [kitr](https://kitr.dev), the TypeScript framework where everything is an add-on. MIT licensed.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { BuildPlatformKey } from "@kitr/core";
|
|
2
|
+
/** Site-wide values for `seo()` and the build: name, canonical origin, default image, handle and locale. */
|
|
3
|
+
export interface SeoSite {
|
|
4
|
+
/** Appended to page titles (`Pricing · Acme`) and sent as `og:site_name`. */
|
|
5
|
+
name?: string;
|
|
6
|
+
/** Canonical origin (`https://example.com`). Turns on the sitemap for prerendered routes. */
|
|
7
|
+
url?: string;
|
|
8
|
+
/** Share image for pages that set none. Use an absolute URL. */
|
|
9
|
+
image?: string;
|
|
10
|
+
/** The site's X (Twitter) handle, sent as `twitter:site`. */
|
|
11
|
+
twitter?: string;
|
|
12
|
+
/** Sent as `og:locale`, such as `en_US`. */
|
|
13
|
+
locale?: string;
|
|
14
|
+
}
|
|
15
|
+
/** Options for `config/seo.ts`. The build reads `prerender`, `site.url` and `robots`; `seo()` reads `site`; the router reads `ssr`. */
|
|
16
|
+
export interface SeoConfig {
|
|
17
|
+
/** Site-wide defaults for `seo()`. */
|
|
18
|
+
site?: SeoSite;
|
|
19
|
+
/** Default rendering for routes that set no `ssr` of their own (the router's `defaultSsr`). `ssrRoute()` overrides it per route. */
|
|
20
|
+
ssr?: boolean | 'data-only';
|
|
21
|
+
/** Routes to prerender at build time; `crawlLinks` follows links from them. A `static` web platform prerenders everything. */
|
|
22
|
+
prerender?: string[] | {
|
|
23
|
+
routes: string[];
|
|
24
|
+
crawlLinks?: boolean;
|
|
25
|
+
};
|
|
26
|
+
/** Rules for `robotsTxt()`, which the build writes to `robots.txt` unless `public/robots.txt` exists. `false` writes none. */
|
|
27
|
+
robots?: false | {
|
|
28
|
+
allow?: string[];
|
|
29
|
+
disallow?: string[];
|
|
30
|
+
sitemap?: boolean;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
declare module '@kitr/core' {
|
|
34
|
+
interface ConfigRegistry {
|
|
35
|
+
seo: SeoConfig;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Types the default export of `config/seo.ts`. The config lives on the app (`app.config.get('seo')`,
|
|
40
|
+
* set from the `config` that `app/app.ts` passes to `createApp`), not in this module, so two apps
|
|
41
|
+
* in one process keep their own site defaults and `seo()` reads them from the app it is given.
|
|
42
|
+
*/
|
|
43
|
+
export declare const defineConfig: <const C extends SeoConfig>(config: C) => C;
|
|
44
|
+
/** What one page passes to `seo()`. Every field is optional; `site` supplies the defaults. */
|
|
45
|
+
export interface SeoInput {
|
|
46
|
+
/** The page title, without the site name. */
|
|
47
|
+
title?: string;
|
|
48
|
+
/** The page summary for search results and share cards. */
|
|
49
|
+
description?: string;
|
|
50
|
+
/** Share image for this page; replaces `site.image`. Use an absolute URL. */
|
|
51
|
+
image?: string;
|
|
52
|
+
/** The page's canonical URL; falls back to `site.url`, so pass it on every page. */
|
|
53
|
+
url?: string;
|
|
54
|
+
/** `og:type`. Default `website`. */
|
|
55
|
+
type?: 'website' | 'article' | 'product';
|
|
56
|
+
/** Adds `robots: noindex, nofollow`. */
|
|
57
|
+
noIndex?: boolean;
|
|
58
|
+
/** Joined into a `keywords` meta tag. */
|
|
59
|
+
keywords?: string[];
|
|
60
|
+
/** Site defaults. Default: `site` from the app's `config/seo.ts`, found through the context passed as `seo`'s second argument. */
|
|
61
|
+
site?: SeoSite;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Where `seo()` finds the app for the site defaults: the argument of a route's `head` (its
|
|
65
|
+
* `match.context.app`, which `createKitrRouter` puts in every route's context), a loader or
|
|
66
|
+
* `beforeLoad` context, or the app itself.
|
|
67
|
+
*/
|
|
68
|
+
export type SeoContext = object;
|
|
69
|
+
/**
|
|
70
|
+
* Meta tags for a route's `head()`: `head: (ctx) => seo({ title, description }, ctx)`.
|
|
71
|
+
* Covers title, description, Open Graph, Twitter cards, canonical and robots. Pass the `head`
|
|
72
|
+
* 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.
|
|
74
|
+
*/
|
|
75
|
+
export declare function seo(input: SeoInput, context?: SeoContext): {
|
|
76
|
+
meta: Record<string, string>[];
|
|
77
|
+
links: Record<string, string>[];
|
|
78
|
+
};
|
|
79
|
+
/** A JSON-LD script for `head().scripts`: `scripts: [jsonLd({ '@type': 'Article', headline })]`. */
|
|
80
|
+
export declare function jsonLd(data: Record<string, unknown> & {
|
|
81
|
+
'@type': string;
|
|
82
|
+
}): {
|
|
83
|
+
type: string;
|
|
84
|
+
children: string;
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* JSON for inside a `<script>`: `<`, `>` and `&` as unicode escapes so a value such as
|
|
88
|
+
* `</script><script>…` from a user's title can't close the tag, and U+2028/U+2029 escaped too.
|
|
89
|
+
* Still valid JSON: parsers read the escapes back as the same characters.
|
|
90
|
+
*/
|
|
91
|
+
export declare function safeJson(value: unknown): string;
|
|
92
|
+
export type SsrMode = boolean | 'data-only';
|
|
93
|
+
/**
|
|
94
|
+
* Route option presets. Spread into `createFileRoute(...)({ ...ssrRoute('data-only'), ... })`.
|
|
95
|
+
* A table picks the mode per platform at build time, by platform name from kitrrc.ts, then type,
|
|
96
|
+
* then `default`: `ssrRoute({ www: true, web: 'data-only', default: false })` renders the marketing
|
|
97
|
+
* build on the server, other web builds data-only, and native shells on the client.
|
|
98
|
+
*/
|
|
99
|
+
export declare function ssrRoute(mode: SsrMode | (Partial<Record<BuildPlatformKey, SsrMode>> & {
|
|
100
|
+
default: SsrMode;
|
|
101
|
+
})): {
|
|
102
|
+
ssr: SsrMode;
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* A `cache-control` header for TanStack Start's `headers` route option:
|
|
106
|
+
* `headers: () => cacheHeaders({ sMaxAge: 60 })`. Public unless `private` is set.
|
|
107
|
+
*/
|
|
108
|
+
export declare function cacheHeaders(options?: {
|
|
109
|
+
maxAge?: number;
|
|
110
|
+
sMaxAge?: number;
|
|
111
|
+
staleWhileRevalidate?: number;
|
|
112
|
+
private?: boolean;
|
|
113
|
+
}): {
|
|
114
|
+
'cache-control': string;
|
|
115
|
+
};
|
|
116
|
+
/** `robots.txt` text from the config, or null when `robots` is false. The build writes it for web platforms. */
|
|
117
|
+
export declare function robotsTxt(config: SeoConfig): string | null;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { createDefineConfig, forBuildPlatform } from "@kitr/core";
|
|
2
|
+
// src/index.ts
|
|
3
|
+
/**
|
|
4
|
+
* Types the default export of `config/seo.ts`. The config lives on the app (`app.config.get('seo')`,
|
|
5
|
+
* set from the `config` that `app/app.ts` passes to `createApp`), not in this module, so two apps
|
|
6
|
+
* in one process keep their own site defaults and `seo()` reads them from the app it is given.
|
|
7
|
+
*/
|
|
8
|
+
const defineConfig = createDefineConfig();
|
|
9
|
+
function appOf(context) {
|
|
10
|
+
const isApp = (value) => !!value && typeof value === "object" && "config" in value && "container" in value;
|
|
11
|
+
const candidates = (value) => {
|
|
12
|
+
const v = value;
|
|
13
|
+
return [
|
|
14
|
+
value,
|
|
15
|
+
v?.app,
|
|
16
|
+
(v?.context)?.app,
|
|
17
|
+
(v?.match?.context)?.app
|
|
18
|
+
];
|
|
19
|
+
};
|
|
20
|
+
return candidates(context).find(isApp);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Meta tags for a route's `head()`: `head: (ctx) => seo({ title, description }, ctx)`.
|
|
24
|
+
* Covers title, description, Open Graph, Twitter cards, canonical and robots. Pass the `head`
|
|
25
|
+
* 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.
|
|
27
|
+
*/
|
|
28
|
+
function seo(input, context) {
|
|
29
|
+
const configured = appOf(context)?.config.get("seo", {});
|
|
30
|
+
const site = input.site ?? configured?.site ?? {};
|
|
31
|
+
const title = input.title ? site.name && input.title !== site.name ? `${input.title} · ${site.name}` : input.title : site.name;
|
|
32
|
+
const image = input.image ?? site.image;
|
|
33
|
+
const url = input.url ?? site.url;
|
|
34
|
+
const meta = [];
|
|
35
|
+
if (title) meta.push({ title }, {
|
|
36
|
+
property: "og:title",
|
|
37
|
+
content: input.title ?? title
|
|
38
|
+
}, {
|
|
39
|
+
name: "twitter:title",
|
|
40
|
+
content: input.title ?? title
|
|
41
|
+
});
|
|
42
|
+
if (input.description) meta.push({
|
|
43
|
+
name: "description",
|
|
44
|
+
content: input.description
|
|
45
|
+
}, {
|
|
46
|
+
property: "og:description",
|
|
47
|
+
content: input.description
|
|
48
|
+
}, {
|
|
49
|
+
name: "twitter:description",
|
|
50
|
+
content: input.description
|
|
51
|
+
});
|
|
52
|
+
meta.push({
|
|
53
|
+
property: "og:type",
|
|
54
|
+
content: input.type ?? "website"
|
|
55
|
+
});
|
|
56
|
+
if (site.name) meta.push({
|
|
57
|
+
property: "og:site_name",
|
|
58
|
+
content: site.name
|
|
59
|
+
});
|
|
60
|
+
if (site.locale) meta.push({
|
|
61
|
+
property: "og:locale",
|
|
62
|
+
content: site.locale
|
|
63
|
+
});
|
|
64
|
+
if (url) meta.push({
|
|
65
|
+
property: "og:url",
|
|
66
|
+
content: url
|
|
67
|
+
});
|
|
68
|
+
if (image) meta.push({
|
|
69
|
+
property: "og:image",
|
|
70
|
+
content: image
|
|
71
|
+
}, {
|
|
72
|
+
name: "twitter:image",
|
|
73
|
+
content: image
|
|
74
|
+
}, {
|
|
75
|
+
name: "twitter:card",
|
|
76
|
+
content: "summary_large_image"
|
|
77
|
+
});
|
|
78
|
+
else meta.push({
|
|
79
|
+
name: "twitter:card",
|
|
80
|
+
content: "summary"
|
|
81
|
+
});
|
|
82
|
+
if (site.twitter) meta.push({
|
|
83
|
+
name: "twitter:site",
|
|
84
|
+
content: site.twitter
|
|
85
|
+
});
|
|
86
|
+
if (input.keywords?.length) meta.push({
|
|
87
|
+
name: "keywords",
|
|
88
|
+
content: input.keywords.join(", ")
|
|
89
|
+
});
|
|
90
|
+
if (input.noIndex) meta.push({
|
|
91
|
+
name: "robots",
|
|
92
|
+
content: "noindex, nofollow"
|
|
93
|
+
});
|
|
94
|
+
return {
|
|
95
|
+
meta,
|
|
96
|
+
links: url ? [{
|
|
97
|
+
rel: "canonical",
|
|
98
|
+
href: url
|
|
99
|
+
}] : []
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/** A JSON-LD script for `head().scripts`: `scripts: [jsonLd({ '@type': 'Article', headline })]`. */
|
|
103
|
+
function jsonLd(data) {
|
|
104
|
+
return {
|
|
105
|
+
type: "application/ld+json",
|
|
106
|
+
children: safeJson({
|
|
107
|
+
"@context": "https://schema.org",
|
|
108
|
+
...data
|
|
109
|
+
})
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* JSON for inside a `<script>`: `<`, `>` and `&` as unicode escapes so a value such as
|
|
114
|
+
* `<\/script><script>…` from a user's title can't close the tag, and U+2028/U+2029 escaped too.
|
|
115
|
+
* Still valid JSON: parsers read the escapes back as the same characters.
|
|
116
|
+
*/
|
|
117
|
+
function safeJson(value) {
|
|
118
|
+
return JSON.stringify(value).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Route option presets. Spread into `createFileRoute(...)({ ...ssrRoute('data-only'), ... })`.
|
|
122
|
+
* A table picks the mode per platform at build time, by platform name from kitrrc.ts, then type,
|
|
123
|
+
* then `default`: `ssrRoute({ www: true, web: 'data-only', default: false })` renders the marketing
|
|
124
|
+
* build on the server, other web builds data-only, and native shells on the client.
|
|
125
|
+
*/
|
|
126
|
+
function ssrRoute(mode) {
|
|
127
|
+
if (typeof mode === "object") return { ssr: forBuildPlatform(mode) };
|
|
128
|
+
return { ssr: mode };
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* A `cache-control` header for TanStack Start's `headers` route option:
|
|
132
|
+
* `headers: () => cacheHeaders({ sMaxAge: 60 })`. Public unless `private` is set.
|
|
133
|
+
*/
|
|
134
|
+
function cacheHeaders(options = {}) {
|
|
135
|
+
const parts = [options.private ? "private" : "public"];
|
|
136
|
+
if (options.maxAge !== void 0) parts.push(`max-age=${options.maxAge}`);
|
|
137
|
+
if (options.sMaxAge !== void 0) parts.push(`s-maxage=${options.sMaxAge}`);
|
|
138
|
+
if (options.staleWhileRevalidate !== void 0) parts.push(`stale-while-revalidate=${options.staleWhileRevalidate}`);
|
|
139
|
+
return { "cache-control": parts.join(", ") };
|
|
140
|
+
}
|
|
141
|
+
/** `robots.txt` text from the config, or null when `robots` is false. The build writes it for web platforms. */
|
|
142
|
+
function robotsTxt(config) {
|
|
143
|
+
if (config.robots === false) return null;
|
|
144
|
+
const rules = config.robots ?? {};
|
|
145
|
+
const lines = ["User-agent: *"];
|
|
146
|
+
for (const p of rules.allow ?? ["/"]) lines.push(`Allow: ${p}`);
|
|
147
|
+
for (const p of rules.disallow ?? []) lines.push(`Disallow: ${p}`);
|
|
148
|
+
if ((rules.sitemap ?? true) && config.site?.url) lines.push("", `Sitemap: ${config.site.url.replace(/\/$/, "")}/sitemap.xml`);
|
|
149
|
+
return `${lines.join("\n")}\n`;
|
|
150
|
+
}
|
|
151
|
+
export { cacheHeaders, defineConfig, jsonLd, robotsTxt, safeJson, seo, ssrRoute };
|
package/dist/kitr.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** `kitr add seo`: head helpers, JSON-LD, per-route rendering defaults, prerender list, robots and sitemap. */
|
|
2
|
+
declare const _default: {
|
|
3
|
+
readonly name: "seo";
|
|
4
|
+
readonly client: true;
|
|
5
|
+
readonly add: (ctx: import("@kitr/core/package").AddContext) => void;
|
|
6
|
+
readonly remove: (ctx: import("@kitr/core/package").AddContext) => void;
|
|
7
|
+
readonly eject: {
|
|
8
|
+
readonly '@kitr/seo': "meta tags in head()";
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
export { _default as default };
|
package/dist/kitr.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { definePackage } from "@kitr/core/package";
|
|
2
|
+
// src/kitr.ts
|
|
3
|
+
/** `kitr add seo`: head helpers, JSON-LD, per-route rendering defaults, prerender list, robots and sitemap. */
|
|
4
|
+
var kitr_default = definePackage({
|
|
5
|
+
name: "seo",
|
|
6
|
+
client: true,
|
|
7
|
+
add(ctx) {
|
|
8
|
+
ctx.config("seo", `import { defineConfig } from '@kitr/seo'\n\nexport default defineConfig({\n site: { name: '${ctx.pkg.name ?? "My app"}' },\n // Routes prerendered at build time by a web platform in ssr mode; a static web platform prerenders everything.\n prerender: [],\n})\n`);
|
|
9
|
+
ctx.agents.add("seo", `- \`head: (ctx) => seo({ title, description }, ctx)\` from \`@kitr/seo\` writes title, Open Graph, Twitter and canonical tags; \`jsonLd({...})\` adds structured data. Site defaults live in \`config/seo.ts\`.\n- Use \`...ssrRoute('data-only')\` for heavy, non-SEO pages; \`cacheHeaders({ sMaxAge })\` for the CDN.`);
|
|
10
|
+
},
|
|
11
|
+
remove(ctx) {
|
|
12
|
+
ctx.agents.remove("seo");
|
|
13
|
+
},
|
|
14
|
+
eject: { "@kitr/seo": "meta tags in head()" }
|
|
15
|
+
});
|
|
16
|
+
export { kitr_default as default };
|
package/docs/api.md
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: API reference
|
|
3
|
+
description: Every export of @kitr/seo, with its signature.
|
|
4
|
+
order: 2
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# API reference
|
|
8
|
+
|
|
9
|
+
Every export of `@kitr/seo`, read from the published type definitions. The [guide](/packages/seo/guide) shows them in use.
|
|
10
|
+
|
|
11
|
+
## @kitr/seo
|
|
12
|
+
|
|
13
|
+
### cacheHeaders
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
function cacheHeaders(options?: {
|
|
17
|
+
maxAge?: number;
|
|
18
|
+
sMaxAge?: number;
|
|
19
|
+
staleWhileRevalidate?: number;
|
|
20
|
+
private?: boolean;
|
|
21
|
+
}): {
|
|
22
|
+
'cache-control': string;
|
|
23
|
+
};
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
A `cache-control` header for TanStack Start's `headers` route option:
|
|
27
|
+
`headers: () => cacheHeaders({ sMaxAge: 60 })`. Public unless `private` is set.
|
|
28
|
+
|
|
29
|
+
### jsonLd
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
function jsonLd(data: Record<string, unknown> & {
|
|
33
|
+
'@type': string;
|
|
34
|
+
}): {
|
|
35
|
+
type: string;
|
|
36
|
+
children: string;
|
|
37
|
+
};
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
A JSON-LD script for `head().scripts`: `scripts: [jsonLd({ '@type': 'Article', headline })]`.
|
|
41
|
+
|
|
42
|
+
### robotsTxt
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
function robotsTxt(config: SeoConfig): string | null;
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`robots.txt` text from the config, or null when `robots` is false. The build writes it for web platforms.
|
|
49
|
+
|
|
50
|
+
### safeJson
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
function safeJson(value: unknown): string;
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
JSON for inside a `<script>`: `<`, `>` and `&` as unicode escapes so a value such as
|
|
57
|
+
`</script><script>…` from a user's title can't close the tag, and U+2028/U+2029 escaped too.
|
|
58
|
+
Still valid JSON: parsers read the escapes back as the same characters.
|
|
59
|
+
|
|
60
|
+
### seo
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
function seo(input: SeoInput, context?: SeoContext): {
|
|
64
|
+
meta: Record<string, string>[];
|
|
65
|
+
links: Record<string, string>[];
|
|
66
|
+
};
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Meta tags for a route's `head()`: `head: (ctx) => seo({ title, description }, ctx)`.
|
|
70
|
+
Covers title, description, Open Graph, Twitter cards, canonical and robots. Pass the `head`
|
|
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.
|
|
73
|
+
|
|
74
|
+
### ssrRoute
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
function ssrRoute(mode: SsrMode | (Partial<Record<BuildPlatformKey, SsrMode>> & {
|
|
78
|
+
default: SsrMode;
|
|
79
|
+
})): {
|
|
80
|
+
ssr: SsrMode;
|
|
81
|
+
};
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Route option presets. Spread into `createFileRoute(...)({ ...ssrRoute('data-only'), ... })`.
|
|
85
|
+
A table picks the mode per platform at build time, by platform name from kitrrc.ts, then type,
|
|
86
|
+
then `default`: `ssrRoute({ www: true, web: 'data-only', default: false })` renders the marketing
|
|
87
|
+
build on the server, other web builds data-only, and native shells on the client.
|
|
88
|
+
|
|
89
|
+
### defineConfig
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
const defineConfig: <const C extends SeoConfig>(config: C) => C;
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Types the default export of `config/seo.ts`. The config lives on the app (`app.config.get('seo')`,
|
|
96
|
+
set from the `config` that `app/app.ts` passes to `createApp`), not in this module, so two apps
|
|
97
|
+
in one process keep their own site defaults and `seo()` reads them from the app it is given.
|
|
98
|
+
|
|
99
|
+
### SeoConfig
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
interface SeoConfig {
|
|
103
|
+
/** Site-wide defaults for `seo()`. */
|
|
104
|
+
site?: SeoSite;
|
|
105
|
+
/** Default rendering for routes that set no `ssr` of their own (the router's `defaultSsr`). `ssrRoute()` overrides it per route. */
|
|
106
|
+
ssr?: boolean | 'data-only';
|
|
107
|
+
/** Routes to prerender at build time; `crawlLinks` follows links from them. A `static` web platform prerenders everything. */
|
|
108
|
+
prerender?: string[] | {
|
|
109
|
+
routes: string[];
|
|
110
|
+
crawlLinks?: boolean;
|
|
111
|
+
};
|
|
112
|
+
/** Rules for `robotsTxt()`, which the build writes to `robots.txt` unless `public/robots.txt` exists. `false` writes none. */
|
|
113
|
+
robots?: false | {
|
|
114
|
+
allow?: string[];
|
|
115
|
+
disallow?: string[];
|
|
116
|
+
sitemap?: boolean;
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Options for `config/seo.ts`. The build reads `prerender`, `site.url` and `robots`; `seo()` reads `site`; the router reads `ssr`.
|
|
122
|
+
|
|
123
|
+
### SeoInput
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
interface SeoInput {
|
|
127
|
+
/** The page title, without the site name. */
|
|
128
|
+
title?: string;
|
|
129
|
+
/** The page summary for search results and share cards. */
|
|
130
|
+
description?: string;
|
|
131
|
+
/** Share image for this page; replaces `site.image`. Use an absolute URL. */
|
|
132
|
+
image?: string;
|
|
133
|
+
/** The page's canonical URL; falls back to `site.url`, so pass it on every page. */
|
|
134
|
+
url?: string;
|
|
135
|
+
/** `og:type`. Default `website`. */
|
|
136
|
+
type?: 'website' | 'article' | 'product';
|
|
137
|
+
/** Adds `robots: noindex, nofollow`. */
|
|
138
|
+
noIndex?: boolean;
|
|
139
|
+
/** Joined into a `keywords` meta tag. */
|
|
140
|
+
keywords?: string[];
|
|
141
|
+
/** Site defaults. Default: `site` from the app's `config/seo.ts`, found through the context passed as `seo`'s second argument. */
|
|
142
|
+
site?: SeoSite;
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
What one page passes to `seo()`. Every field is optional; `site` supplies the defaults.
|
|
147
|
+
|
|
148
|
+
### SeoSite
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
interface SeoSite {
|
|
152
|
+
/** Appended to page titles (`Pricing · Acme`) and sent as `og:site_name`. */
|
|
153
|
+
name?: string;
|
|
154
|
+
/** Canonical origin (`https://example.com`). Turns on the sitemap for prerendered routes. */
|
|
155
|
+
url?: string;
|
|
156
|
+
/** Share image for pages that set none. Use an absolute URL. */
|
|
157
|
+
image?: string;
|
|
158
|
+
/** The site's X (Twitter) handle, sent as `twitter:site`. */
|
|
159
|
+
twitter?: string;
|
|
160
|
+
/** Sent as `og:locale`, such as `en_US`. */
|
|
161
|
+
locale?: string;
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Site-wide values for `seo()` and the build: name, canonical origin, default image, handle and locale.
|
|
166
|
+
|
|
167
|
+
### SeoContext
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
type SeoContext = object;
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Where `seo()` finds the app for the site defaults: the argument of a route's `head` (its
|
|
174
|
+
`match.context.app`, which `createKitrRouter` puts in every route's context), a loader or
|
|
175
|
+
`beforeLoad` context, or the app itself.
|
|
176
|
+
|
|
177
|
+
### SsrMode
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
type SsrMode = boolean | 'data-only';
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
<!-- GENERATED by scripts/api-docs.mjs from the type definitions. Do not edit. -->
|
package/docs/index.md
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: SEO
|
|
3
|
+
description: Give every page a title, a share preview and a sitemap entry so search engines and chat apps show it well.
|
|
4
|
+
order: 1
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# SEO
|
|
8
|
+
|
|
9
|
+
Paste a link into Slack or search for your product, and what people see comes from a few tags: title, description, share image. `@kitr/seo` writes them from one call, `seo({ title, description })`, adds structured data, and tells the build which pages to prerender. It builds on [TanStack Router's `head`](https://tanstack.com/router/latest/docs/framework/react/guide/document-head-management) and [TanStack Start's prerendering](https://tanstack.com/start/latest/docs/framework/react/guide/static-prerendering).
|
|
10
|
+
|
|
11
|
+
> [!SUMMARY]
|
|
12
|
+
> Gives every page a title, a share preview and a sitemap entry so search engines and chat apps show it well. Add it with `kitr add seo`; the overview is at [/packages/seo](/packages/seo).
|
|
13
|
+
|
|
14
|
+
The helpers are plain functions that the server renders into HTML and the client keeps up to date. The examples follow Acme's marketing site at `acme.com`.
|
|
15
|
+
|
|
16
|
+
> [!WARNING] Needs ssr or static mode
|
|
17
|
+
> Tags only help when the HTML reaches crawlers. In an `spa` app, crawlers and link previews see the same empty shell for every URL.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
kitr add seo
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
<details>
|
|
26
|
+
<summary>Everything kitr add changes</summary>
|
|
27
|
+
|
|
28
|
+
What `kitr add` does:
|
|
29
|
+
|
|
30
|
+
- Installs `@kitr/seo`.
|
|
31
|
+
- Adds `'@kitr/seo'` to `packages` in `kitrrc.ts`. It registers no provider, so nothing runs at startup.
|
|
32
|
+
- Writes `config/seo.ts` (shown in [Configure](#configure)) and regenerates `config/index.ts`.
|
|
33
|
+
- Adds a `## kitr: seo` section to `AGENTS.md` telling coding agents to set `head: (ctx) => seo({ title, description }, ctx)` on routes, to add structured data with `jsonLd()`, and to use `ssrRoute()` and `cacheHeaders()` for rendering and CDN caching.
|
|
34
|
+
|
|
35
|
+
It asks no questions and adds no env variables. The `fullstack`, `saas`, `site` and `realtime` kits include seo; the `site` kit writes its own `config/seo.ts`. In a server-only project `kitr add seo` prints a warning and skips it.
|
|
36
|
+
|
|
37
|
+
`kitr remove seo` removes the `packages` entry, the dependency and the `AGENTS.md` section, and deletes `config/seo.ts`. It leaves the `seo()` calls in your routes.
|
|
38
|
+
|
|
39
|
+
</details>
|
|
40
|
+
|
|
41
|
+
To edit `package.json` without running the package manager, pass `--no-install`:
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
kitr add seo --no-install
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Configure
|
|
48
|
+
|
|
49
|
+
`kitr add seo` writes this file, with `name` set to the `name` in your `package.json` (here `acme-site`):
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
// config/seo.ts
|
|
53
|
+
import { defineConfig } from '@kitr/seo'
|
|
54
|
+
|
|
55
|
+
export default defineConfig({
|
|
56
|
+
site: { name: 'acme-site' },
|
|
57
|
+
// Routes prerendered at build time by a web platform in ssr mode; a static web platform prerenders everything.
|
|
58
|
+
prerender: [],
|
|
59
|
+
})
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Fill in the rest of `site` before you launch:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// config/seo.ts
|
|
66
|
+
import { defineConfig } from '@kitr/seo'
|
|
67
|
+
|
|
68
|
+
export default defineConfig({
|
|
69
|
+
site: {
|
|
70
|
+
name: 'Acme',
|
|
71
|
+
url: 'https://acme.com',
|
|
72
|
+
image: 'https://acme.com/og/default.png',
|
|
73
|
+
twitter: '@acme',
|
|
74
|
+
locale: 'en_US',
|
|
75
|
+
},
|
|
76
|
+
prerender: { routes: ['/', '/pricing', '/blog'], crawlLinks: true },
|
|
77
|
+
robots: { disallow: ['/api'] },
|
|
78
|
+
})
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
| Option | Default | Description |
|
|
82
|
+
| --- | --- | --- |
|
|
83
|
+
| `site.name` | none | Appended to page titles (`Pricing · Acme`) and sent as `og:site_name`. Also the title of a page that sets none. |
|
|
84
|
+
| `site.url` | none | The site's address, `https://acme.com`. Turns on the [sitemap](#generate-a-sitemap). |
|
|
85
|
+
| `site.image` | none | The share image for pages that set none. Use an absolute URL. |
|
|
86
|
+
| `site.twitter` | none | The site's X (Twitter) handle, sent as `twitter:site`. |
|
|
87
|
+
| `site.locale` | none | Sent as `og:locale`, such as `en_US`. |
|
|
88
|
+
| `prerender` | `[]` | Routes the build renders to HTML files. An array of paths, or `{ routes, crawlLinks }`. |
|
|
89
|
+
| `prerender.routes` | `[]` | The paths to prerender, such as `['/', '/pricing']`. |
|
|
90
|
+
| `prerender.crawlLinks` | `false` | Also prerender every page linked from those routes. |
|
|
91
|
+
| `robots` | `{}` | Rules for the `robots.txt` the build writes (see [Write robots.txt](#write-robotstxt)). `false` writes none. |
|
|
92
|
+
| `robots.allow` | `['/']` | `Allow:` lines. |
|
|
93
|
+
| `robots.disallow` | `[]` | `Disallow:` lines. |
|
|
94
|
+
| `robots.sitemap` | `true` | Add `Sitemap: <site.url>/sitemap.xml` when `site.url` is set. |
|
|
95
|
+
| `ssr` | `true` | How routes without their own `ssr` render: `true`, `'data-only'` or `false`. [`ssrRoute()`](#choose-how-a-route-renders) on a route overrides it. |
|
|
96
|
+
|
|
97
|
+
> [!WARNING] Write them as literals
|
|
98
|
+
> The build reads `prerender` and `site.url` from the file's source text without running it, so a value from an env variable or another file is not seen.
|
|
99
|
+
|
|
100
|
+
`seo()` uses `site` as its defaults, finding the app through the `ctx` you pass it.
|
|
101
|
+
|
|
102
|
+
## Give a page its title
|
|
103
|
+
|
|
104
|
+
Set `head` on a route and return `seo()` from it, passing the `head` argument as the second argument so it can find the app's site defaults:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
// app/routes.ts
|
|
108
|
+
import { seo } from '@kitr/seo'
|
|
109
|
+
|
|
110
|
+
r.page('/pricing', {
|
|
111
|
+
head: (ctx) => seo({ title: 'Pricing', description: 'Plans for every team size.' }, ctx),
|
|
112
|
+
lazy: () => import('./pages/pricing'),
|
|
113
|
+
})
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
```tsx
|
|
117
|
+
// app/pages/pricing.tsx
|
|
118
|
+
export default function PricingPage() {
|
|
119
|
+
return <h1>Pricing</h1>
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
View the page source of `/pricing`. The root route's `<HeadContent />` renders the tags into `<head>`:
|
|
124
|
+
|
|
125
|
+
```html
|
|
126
|
+
<title>Pricing</title>
|
|
127
|
+
<meta property="og:title" content="Pricing">
|
|
128
|
+
<meta name="twitter:title" content="Pricing">
|
|
129
|
+
<meta name="description" content="Plans for every team size.">
|
|
130
|
+
<meta property="og:description" content="Plans for every team size.">
|
|
131
|
+
<meta name="twitter:description" content="Plans for every team size.">
|
|
132
|
+
<meta property="og:type" content="website">
|
|
133
|
+
<meta name="twitter:card" content="summary">
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`seo()` returns `{ meta, links }`, the shape TanStack Router's `head` expects; the router swaps the tags on navigation.
|
|
137
|
+
|
|
138
|
+
## Use your site defaults on every page
|
|
139
|
+
|
|
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
|
+
|
|
142
|
+
```ts
|
|
143
|
+
// app/routes.ts
|
|
144
|
+
import { seo } from '@kitr/seo'
|
|
145
|
+
|
|
146
|
+
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
|
+
),
|
|
156
|
+
lazy: () => import('./pages/pricing'),
|
|
157
|
+
})
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
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:
|
|
161
|
+
|
|
162
|
+
| Tag | Comes from |
|
|
163
|
+
| --- | --- |
|
|
164
|
+
| `<title>` | `title · site.name`; `title` alone when it equals `site.name`; `site.name` when there is no `title`. |
|
|
165
|
+
| `og:title`, `twitter:title` | `title` without the site name. |
|
|
166
|
+
| `description`, `og:description`, `twitter:description` | `description`, only when set. |
|
|
167
|
+
| `og:url`, canonical link | `url`, else `site.url`. |
|
|
168
|
+
| `og:image`, `twitter:image` | `image`, else `site.image`. |
|
|
169
|
+
| `twitter:card` | `summary_large_image` when there is an image, else `summary`. |
|
|
170
|
+
| `og:type` | `type`, default `'website'`. |
|
|
171
|
+
|
|
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.
|
|
174
|
+
|
|
175
|
+
## Set defaults in the root route
|
|
176
|
+
|
|
177
|
+
Tags from the root route apply to every page; a child route's `<title>` and tags with the same `name` or `property` replace the root's. Put the site-wide description in the root:
|
|
178
|
+
|
|
179
|
+
```tsx
|
|
180
|
+
// app/document.tsx
|
|
181
|
+
import { HeadContent, Outlet, Scripts } from '@kitr/router'
|
|
182
|
+
import { seo } from '@kitr/seo'
|
|
183
|
+
|
|
184
|
+
/** `app/routes.ts` passes this to `.root({ head, component: RootDocument })`. */
|
|
185
|
+
export function head(ctx: object) {
|
|
186
|
+
return {
|
|
187
|
+
meta: [
|
|
188
|
+
{ charSet: 'utf-8' },
|
|
189
|
+
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
|
190
|
+
...seo({ description: 'Acme keeps your team’s projects, orders and invoices in one place.' }, ctx).meta,
|
|
191
|
+
],
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function RootDocument() {
|
|
196
|
+
return (
|
|
197
|
+
<html lang="en">
|
|
198
|
+
<head>
|
|
199
|
+
<HeadContent />
|
|
200
|
+
</head>
|
|
201
|
+
<body>
|
|
202
|
+
<Outlet />
|
|
203
|
+
<Scripts />
|
|
204
|
+
</body>
|
|
205
|
+
</html>
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
> [!WARNING] Spread only .meta in the root
|
|
211
|
+
> TanStack Router merges `links` from every matched route without removing duplicates, so a canonical link in the root plus the page's gives two canonical URLs.
|
|
212
|
+
|
|
213
|
+
## Add a share image
|
|
214
|
+
|
|
215
|
+
Chat apps show `og:image` as a large card. Pass an absolute URL, since most crawlers do not resolve a relative path:
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
// app/routes.ts
|
|
219
|
+
import { seo } from '@kitr/seo'
|
|
220
|
+
|
|
221
|
+
r.page('/pricing', {
|
|
222
|
+
head: (ctx) =>
|
|
223
|
+
seo(
|
|
224
|
+
{
|
|
225
|
+
title: 'Pricing',
|
|
226
|
+
description: 'Plans for every team size.',
|
|
227
|
+
url: 'https://acme.com/pricing',
|
|
228
|
+
image: 'https://acme.com/og/pricing.png',
|
|
229
|
+
},
|
|
230
|
+
ctx,
|
|
231
|
+
),
|
|
232
|
+
lazy: () => import('./pages/pricing'),
|
|
233
|
+
})
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
```tsx
|
|
237
|
+
// app/pages/pricing.tsx
|
|
238
|
+
export default () => <h1>Pricing</h1>
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
A 1200 × 630 PNG or JPEG works everywhere. Check the result with a preview tool such as [opengraph.xyz](https://www.opengraph.xyz) after you deploy.
|
|
242
|
+
|
|
243
|
+
## Describe an article
|
|
244
|
+
|
|
245
|
+
For a blog post, build the tags from the loader's data and set `type: 'article'`:
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
// app/routes.ts
|
|
249
|
+
import { notFound } from '@kitr/router'
|
|
250
|
+
import { getPost } from '#app/content/posts'
|
|
251
|
+
import { seo } from '@kitr/seo'
|
|
252
|
+
|
|
253
|
+
r.page('/blog/$slug', {
|
|
254
|
+
loader: ({ params }) => {
|
|
255
|
+
const post = getPost(params.slug)
|
|
256
|
+
if (!post) throw notFound()
|
|
257
|
+
return post
|
|
258
|
+
},
|
|
259
|
+
head: (ctx) =>
|
|
260
|
+
seo(
|
|
261
|
+
{
|
|
262
|
+
title: ctx.loaderData?.title,
|
|
263
|
+
description: ctx.loaderData?.excerpt,
|
|
264
|
+
url: `https://acme.com/blog/${ctx.params.slug}`,
|
|
265
|
+
type: 'article',
|
|
266
|
+
keywords: ctx.loaderData?.tags,
|
|
267
|
+
},
|
|
268
|
+
ctx,
|
|
269
|
+
),
|
|
270
|
+
lazy: () => import('./pages/blog/slug'),
|
|
271
|
+
})
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
```tsx
|
|
275
|
+
// app/pages/blog/slug.tsx
|
|
276
|
+
import { getRouteApi } from '@kitr/router'
|
|
277
|
+
|
|
278
|
+
const route = getRouteApi('/blog/$slug')
|
|
279
|
+
|
|
280
|
+
export default function PostPage() {
|
|
281
|
+
const post = route.useLoaderData()
|
|
282
|
+
return <h1>{post.title}</h1>
|
|
283
|
+
}
|
|
284
|
+
```
|
|
285
|
+
|
|
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.
|
|
287
|
+
|
|
288
|
+
## Keep a page out of search results
|
|
289
|
+
|
|
290
|
+
Sign-in pages, drafts and anything behind a login should stay out of search. Pass `noIndex: true`:
|
|
291
|
+
|
|
292
|
+
```ts
|
|
293
|
+
// app/routes.ts
|
|
294
|
+
import { seo } from '@kitr/seo'
|
|
295
|
+
|
|
296
|
+
r.page('/sign-in', {
|
|
297
|
+
head: (ctx) => seo({ title: 'Sign in', url: 'https://acme.com/sign-in', noIndex: true }, ctx),
|
|
298
|
+
lazy: () => import('./pages/sign-in'),
|
|
299
|
+
})
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
```tsx
|
|
303
|
+
// app/pages/sign-in.tsx
|
|
304
|
+
export default () => <h1>Sign in to Acme</h1>
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
To keep a whole area out, such as `/app`, set `noIndex` in its layout route's `head`.
|
|
308
|
+
|
|
309
|
+
> [!WARNING] Don't also block it in robots.txt
|
|
310
|
+
> Crawlers must load the page to see the `noindex` tag.
|
|
311
|
+
|
|
312
|
+
## Add structured data
|
|
313
|
+
|
|
314
|
+
Structured data tells search engines what a page is about. `jsonLd()` turns a [schema.org](https://schema.org) object into a `<script type="application/ld+json">` for `head().scripts`, and adds `@context`:
|
|
315
|
+
|
|
316
|
+
```ts
|
|
317
|
+
// app/routes.ts
|
|
318
|
+
import { jsonLd, seo } from '@kitr/seo'
|
|
319
|
+
|
|
320
|
+
r.page('/', {
|
|
321
|
+
head: (ctx) => ({
|
|
322
|
+
...seo({ title: 'Acme', url: 'https://acme.com' }, ctx),
|
|
323
|
+
scripts: [
|
|
324
|
+
jsonLd({
|
|
325
|
+
'@type': 'Organization',
|
|
326
|
+
name: 'Acme',
|
|
327
|
+
url: 'https://acme.com',
|
|
328
|
+
logo: 'https://acme.com/logo.png',
|
|
329
|
+
}),
|
|
330
|
+
],
|
|
331
|
+
}),
|
|
332
|
+
lazy: () => import('./pages/index'),
|
|
333
|
+
})
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
```tsx
|
|
337
|
+
// app/pages/index.tsx
|
|
338
|
+
export default () => <h1>Acme</h1>
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
Test the page with Google's [Rich Results Test](https://search.google.com/test/rich-results). `jsonLd()` escapes the JSON for a `<script>` tag (`<`, `>`, `&` and the line separators), so values from users are safe to pass. The same escaping is exported as `safeJson(value)` for your own inline scripts:
|
|
342
|
+
|
|
343
|
+
```ts
|
|
344
|
+
import { safeJson } from '@kitr/seo'
|
|
345
|
+
|
|
346
|
+
safeJson({ headline: 'Q3 </script> report' })
|
|
347
|
+
// => {"headline":"Q3 \u003c/script\u003e report"} (the string's characters)
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
## Prerender pages at build time
|
|
351
|
+
|
|
352
|
+
Pages that are the same for every visitor can be built once into HTML files and served from a CDN. List them in `prerender`:
|
|
353
|
+
|
|
354
|
+
```ts
|
|
355
|
+
// config/seo.ts
|
|
356
|
+
import { defineConfig } from '@kitr/seo'
|
|
357
|
+
|
|
358
|
+
export default defineConfig({
|
|
359
|
+
site: { name: 'Acme', url: 'https://acme.com' },
|
|
360
|
+
prerender: { routes: ['/', '/pricing', '/blog'], crawlLinks: true },
|
|
361
|
+
})
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
Then build the web platform:
|
|
365
|
+
|
|
366
|
+
```sh
|
|
367
|
+
kitr build site
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
Each route renders to `index.html` in its own folder (`/pricing/index.html`). With `crawlLinks: true` it also follows every link on those pages, so each blog post linked from `/blog` is prerendered too. Other pages keep rendering per request.
|
|
371
|
+
|
|
372
|
+
What the build does with `prerender` in each web mode:
|
|
373
|
+
|
|
374
|
+
| Web mode | Effect |
|
|
375
|
+
| --- | --- |
|
|
376
|
+
| `ssr` | Prerenders the listed routes (and the pages they link to, with `crawlLinks`). Everything else renders on the server. |
|
|
377
|
+
| `static` | Prerenders every page, starting from `/` and following links. |
|
|
378
|
+
| `spa` | Ignored. The build writes one HTML shell. |
|
|
379
|
+
|
|
380
|
+
> [!WARNING] No computed lists
|
|
381
|
+
> `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
|
+
|
|
383
|
+
## Generate a sitemap
|
|
384
|
+
|
|
385
|
+
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`.
|
|
386
|
+
|
|
387
|
+
> [!WARNING] Quoted string only
|
|
388
|
+
> `url: process.env.SITE_URL` does not turn the sitemap on: the build matches the source text.
|
|
389
|
+
|
|
390
|
+
## Write robots.txt
|
|
391
|
+
|
|
392
|
+
`robotsTxt(config)` returns the text of a `robots.txt` built from `robots` and `site.url`:
|
|
393
|
+
|
|
394
|
+
```ts
|
|
395
|
+
import { robotsTxt } from '@kitr/seo'
|
|
396
|
+
|
|
397
|
+
robotsTxt({ site: { url: 'https://acme.com' }, robots: { disallow: ['/app', '/api'] } })
|
|
398
|
+
// => 'User-agent: *\nAllow: /\nDisallow: /app\nDisallow: /api\n\nSitemap: https://acme.com/sitemap.xml\n'
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
The web build writes this text to `robots.txt` from `config/seo.ts`. It writes nothing when `robots` is `false`, and leaves your own `public/robots.txt` alone.
|
|
402
|
+
|
|
403
|
+
## Choose how a route renders
|
|
404
|
+
|
|
405
|
+
In `ssr` mode every route renders on the server by default, or as `ssr` in `config/seo.ts` says. Spread `ssrRoute()` into a route to change it for that route:
|
|
406
|
+
|
|
407
|
+
```ts
|
|
408
|
+
// app/routes.ts
|
|
409
|
+
import { ssrRoute } from '@kitr/seo'
|
|
410
|
+
|
|
411
|
+
app.page('/reports', {
|
|
412
|
+
...ssrRoute('data-only'),
|
|
413
|
+
loader: async () => {
|
|
414
|
+
const response = await fetch('https://app.acme.com/api/reports/invoices')
|
|
415
|
+
return (await response.json()) as Array<{ month: string; total: number }>
|
|
416
|
+
},
|
|
417
|
+
lazy: () => import('./pages/reports'),
|
|
418
|
+
})
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
```tsx
|
|
422
|
+
// app/pages/reports.tsx
|
|
423
|
+
import { getRouteApi } from '@kitr/router'
|
|
424
|
+
|
|
425
|
+
const route = getRouteApi('/_app/reports')
|
|
426
|
+
|
|
427
|
+
export default function ReportsPage() {
|
|
428
|
+
const rows = route.useLoaderData()
|
|
429
|
+
return <p>{rows.length} months of invoices</p>
|
|
430
|
+
}
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
| Call | Returns | The route |
|
|
434
|
+
| --- | --- | --- |
|
|
435
|
+
| `ssrRoute(true)` | `{ ssr: true }` | Renders on the server. The default unless `config/seo.ts` sets `ssr`. |
|
|
436
|
+
| `ssrRoute('data-only')` | `{ ssr: 'data-only' }` | Runs the loader on the server, renders in the browser. For heavy charts that crawlers don't need. |
|
|
437
|
+
| `ssrRoute(false)` | `{ ssr: false }` | Loads and renders in the browser only. For pages that need `window`. |
|
|
438
|
+
|
|
439
|
+
> [!WARNING] Crawlers need ssr: true
|
|
440
|
+
> A route that does not render on the server sends no page content to crawlers. See TanStack Start's [selective SSR](https://tanstack.com/start/latest/docs/framework/react/guide/selective-ssr) for how it combines with parent routes.
|
|
441
|
+
|
|
442
|
+
When one route table serves several platforms, the mode can differ per platform, resolved at build time by platform name, then type, then `default`:
|
|
443
|
+
|
|
444
|
+
```ts
|
|
445
|
+
// app/routes.ts
|
|
446
|
+
import { ssrRoute } from '@kitr/seo'
|
|
447
|
+
|
|
448
|
+
r.page('/', {
|
|
449
|
+
// The marketing build renders on the server for crawlers; the authed web app runs the loader
|
|
450
|
+
// on the server and renders in the browser; native shells have no server at all.
|
|
451
|
+
...ssrRoute({ www: true, web: 'data-only', default: false }),
|
|
452
|
+
lazy: () => import('./pages/home'),
|
|
453
|
+
})
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
The value is a constant in each bundle, so there is no run-time check.
|
|
457
|
+
|
|
458
|
+
## Cache pages at the CDN
|
|
459
|
+
|
|
460
|
+
`cacheHeaders()` builds a `cache-control` header for TanStack Start's `headers` route option:
|
|
461
|
+
|
|
462
|
+
```ts
|
|
463
|
+
// app/routes.ts
|
|
464
|
+
import { cacheHeaders } from '@kitr/seo'
|
|
465
|
+
import { seo } from '@kitr/seo'
|
|
466
|
+
|
|
467
|
+
r.page('/blog/', {
|
|
468
|
+
headers: () => cacheHeaders({ sMaxAge: 60, staleWhileRevalidate: 300 }),
|
|
469
|
+
head: (ctx) => seo({ title: 'Blog', url: 'https://acme.com/blog' }, ctx),
|
|
470
|
+
lazy: () => import('./pages/blog/index'),
|
|
471
|
+
})
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
```tsx
|
|
475
|
+
// app/pages/blog/index.tsx
|
|
476
|
+
export default () => <h1>Blog</h1>
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
```ts
|
|
480
|
+
// => { 'cache-control': 'public, s-maxage=60, stale-while-revalidate=300' }
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
| Option | Adds |
|
|
484
|
+
| --- | --- |
|
|
485
|
+
| `maxAge` | `max-age=<seconds>`: how long browsers keep it. |
|
|
486
|
+
| `sMaxAge` | `s-maxage=<seconds>`: how long CDNs and shared caches keep it. |
|
|
487
|
+
| `staleWhileRevalidate` | `stale-while-revalidate=<seconds>`: how long a cache may serve the old copy while fetching a new one. |
|
|
488
|
+
| `private` | Starts with `private` instead of `public`, so shared caches don't store it. |
|
|
489
|
+
|
|
490
|
+
With no options it returns `{ 'cache-control': 'public' }`.
|
|
491
|
+
|
|
492
|
+
> [!WARNING] Private pages
|
|
493
|
+
> For a page that shows the signed-in user, use `cacheHeaders({ private: true, maxAge: 0 })`; a `public` header could let a CDN serve one person's page to another.
|
|
494
|
+
|
|
495
|
+
## Testing
|
|
496
|
+
|
|
497
|
+
`seo()` is a plain function, so test what your pages put in `head` without rendering. Make an app from `config/seo.ts`, pass it as the second argument, and assert on the tags:
|
|
498
|
+
|
|
499
|
+
```ts
|
|
500
|
+
// app/seo.test.ts
|
|
501
|
+
import { createApp } from 'kitr/app'
|
|
502
|
+
import { robotsTxt, seo } from '@kitr/seo'
|
|
503
|
+
import { describe, expect, it } from 'vitest'
|
|
504
|
+
import seoConfig from '#config/seo'
|
|
505
|
+
|
|
506
|
+
const app = createApp({ config: { seo: seoConfig } })
|
|
507
|
+
|
|
508
|
+
describe('seo', () => {
|
|
509
|
+
it('adds the site name and the canonical URL', () => {
|
|
510
|
+
const { meta, links } = seo(
|
|
511
|
+
{
|
|
512
|
+
title: 'Pricing',
|
|
513
|
+
description: 'Plans for every team size.',
|
|
514
|
+
url: 'https://acme.com/pricing',
|
|
515
|
+
},
|
|
516
|
+
app,
|
|
517
|
+
)
|
|
518
|
+
expect(meta).toContainEqual({ title: 'Pricing · Acme' })
|
|
519
|
+
expect(meta).toContainEqual({ property: 'og:title', content: 'Pricing' })
|
|
520
|
+
expect(meta).toContainEqual({ name: 'twitter:card', content: 'summary_large_image' })
|
|
521
|
+
expect(links).toEqual([{ rel: 'canonical', href: 'https://acme.com/pricing' }])
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
it('keeps the sign-in page out of search results', () => {
|
|
525
|
+
const { meta } = seo({ title: 'Sign in', url: 'https://acme.com/sign-in', noIndex: true }, app)
|
|
526
|
+
expect(meta).toContainEqual({ name: 'robots', content: 'noindex, nofollow' })
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
it('points crawlers at the sitemap', () => {
|
|
530
|
+
expect(robotsTxt(seoConfig)).toContain('Sitemap: https://acme.com/sitemap.xml')
|
|
531
|
+
})
|
|
532
|
+
})
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
To check the final HTML of a prerendered page, run `kitr build site` and read `/pricing/index.html` from the build output.
|
|
536
|
+
|
|
537
|
+
## Next steps
|
|
538
|
+
|
|
539
|
+
- [API reference](/packages/seo/api): every export and type.
|
|
540
|
+
- [Web](/packages/web): the `ssr`, `static` and `spa` modes that decide what crawlers see.
|
|
541
|
+
- [Router](/packages/router): routes, loaders and `notFound()`.
|
package/docs/overview.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Overview
|
|
3
|
+
description: Give every page a title, a share preview and a sitemap entry so search engines and chat apps show it well.
|
|
4
|
+
order: 0
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# SEO
|
|
8
|
+
|
|
9
|
+
## What it is
|
|
10
|
+
|
|
11
|
+
`@kitr/seo` gives a route its tags in one call, `seo({ title, description })`, with site defaults from `config/seo.ts`. `jsonLd()` adds structured data, `ssrRoute()` picks how a route renders, and the build writes the sitemap and `robots.txt`.
|
|
12
|
+
|
|
13
|
+
Runs on both sides. Tags only help when crawlers get HTML, so use the [web](/packages/web) platform in `ssr` or `static` mode.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
kitr add seo
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Adds `@kitr/seo` and `config/seo.ts`. No provider. [Every change](/packages/seo/guide#install).
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
Set `head` on a route and return `seo()` from it:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
// app/routes.ts
|
|
29
|
+
import { seo } from '@kitr/seo'
|
|
30
|
+
|
|
31
|
+
r.page('/pricing', {
|
|
32
|
+
head: () => seo({ title: 'Pricing', description: 'Plans for every team size.' }),
|
|
33
|
+
lazy: () => import('./pages/pricing'),
|
|
34
|
+
})
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Open `/pricing` and view the page source. The root route's `<HeadContent />` renders the tags into `<head>`:
|
|
38
|
+
|
|
39
|
+
```html
|
|
40
|
+
<title>Pricing</title>
|
|
41
|
+
<meta property="og:title" content="Pricing">
|
|
42
|
+
<meta name="twitter:title" content="Pricing">
|
|
43
|
+
<meta name="description" content="Plans for every team size.">
|
|
44
|
+
<meta property="og:description" content="Plans for every team size.">
|
|
45
|
+
<meta name="twitter:description" content="Plans for every team size.">
|
|
46
|
+
<meta property="og:type" content="website">
|
|
47
|
+
<meta name="twitter:card" content="summary">
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`seo()` returns `{ meta, links }`, the shape TanStack Router's `head` expects, with Open Graph and Twitter tags filled in. The router swaps tags on each navigation. Next in the guide: [Give a page its title](/packages/seo/guide#give-a-page-its-title).
|
|
51
|
+
|
|
52
|
+
## Built on
|
|
53
|
+
|
|
54
|
+
kitr adds the tag set, site defaults, JSON-LD helpers, the sitemap and `robots.txt`, and per-route rendering and caching choices.
|
|
55
|
+
|
|
56
|
+
## Problems it solves
|
|
57
|
+
|
|
58
|
+
- **Tags written by hand on every page.** [Give a page its title](/packages/seo/guide#give-a-page-its-title).
|
|
59
|
+
- **Blank link previews.** [Add a share image](/packages/seo/guide#add-a-share-image).
|
|
60
|
+
- **No rich results.** [Add structured data](/packages/seo/guide#add-structured-data).
|
|
61
|
+
- **Private pages in search results.** [Keep a page out of search results](/packages/seo/guide#keep-a-page-out-of-search-results).
|
|
62
|
+
- **Crawlers that see an empty shell.** [Prerender pages at build time](/packages/seo/guide#prerender-pages-at-build-time).
|
|
63
|
+
- **A sitemap that drifts from the routes.** [Generate a sitemap](/packages/seo/guide#generate-a-sitemap).
|
|
64
|
+
|
|
65
|
+
## Further reading
|
|
66
|
+
|
|
67
|
+
- [Guide](/packages/seo/guide).
|
|
68
|
+
- [API reference](/packages/seo/api).
|
|
69
|
+
- [TanStack Router document head management](https://tanstack.com/router/latest/docs/framework/react/guide/document-head-management) and [TanStack Start static prerendering](https://tanstack.com/start/latest/docs/framework/react/guide/static-prerendering).
|
|
70
|
+
- [Google Search Central](https://developers.google.com/search/docs).
|
|
71
|
+
- [Web](/packages/web) and [Router](/packages/router).
|
package/package.json
CHANGED
|
@@ -1,6 +1,61 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kitr/seo",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.2.1",
|
|
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
|
+
"kitr": {
|
|
6
|
+
"name": "seo",
|
|
7
|
+
"title": "SEO",
|
|
8
|
+
"summary": "Give every page a title, a share preview and a sitemap entry so search engines and chat apps show it well.",
|
|
9
|
+
"category": "pages",
|
|
10
|
+
"runsOn": "both",
|
|
11
|
+
"client": true,
|
|
12
|
+
"docs": "docs",
|
|
13
|
+
"setup": "./kitr"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/kitrjs/kitr.git",
|
|
20
|
+
"directory": "packages/seo"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://kitr.dev/packages/seo",
|
|
23
|
+
"bugs": "https://github.com/kitrjs/kitr/issues",
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"sideEffects": false,
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"default": "./dist/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./kitr": {
|
|
34
|
+
"types": "./dist/kitr.d.ts",
|
|
35
|
+
"default": "./dist/kitr.js"
|
|
36
|
+
},
|
|
37
|
+
"./package.json": "./package.json"
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"dist",
|
|
41
|
+
"docs"
|
|
42
|
+
],
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@kitr/core": "0.2.1"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {},
|
|
47
|
+
"peerDependenciesMeta": {},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"tsdown": "^0.23.7",
|
|
50
|
+
"typescript": "^5.9.3",
|
|
51
|
+
"@types/node": "^22.19.0"
|
|
52
|
+
},
|
|
53
|
+
"keywords": [
|
|
54
|
+
"kitr",
|
|
55
|
+
"kitr-package"
|
|
56
|
+
],
|
|
57
|
+
"scripts": {
|
|
58
|
+
"build": "tsdown",
|
|
59
|
+
"typecheck": "tsc -p tsconfig.json"
|
|
60
|
+
}
|
|
6
61
|
}
|