@amjadiqbal/start-seo-kit 0.0.0-stage → 0.1.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/CHANGELOG.md +37 -0
- package/LICENSE.md +21 -0
- package/README.md +338 -2
- package/dist/commerce.d.ts +151 -0
- package/dist/commerce.js +315 -0
- package/dist/head-f4tZr5jl.d.ts +218 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +429 -0
- package/dist/json-ld-BkYFVGHw.js +122 -0
- package/dist/og.d.ts +64 -0
- package/dist/og.js +142 -0
- package/dist/sitemap-BiQmwT37.d.ts +99 -0
- package/package.json +89 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@amjadiqbal/start-seo-kit` are documented here.
|
|
4
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [Semantic Versioning](https://semver.org/).
|
|
5
|
+
|
|
6
|
+
## [0.1.1] - 2026-10-23
|
|
7
|
+
|
|
8
|
+
Fixes from a README-versus-code audit of 0.1.0.
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
- `createSitemap`: `/sitemap.xml` now loads each array or function source once per request (a function source was called twice when the sitemap fit in one file). Paginated sources (`count` + `entries`) are still the way to avoid loading a large table per request; the README now says what each source type costs.
|
|
12
|
+
|
|
13
|
+
### Docs
|
|
14
|
+
- JSON-LD: the kit passes objects to TanStack Router, which serialises and HTML-escapes them; the README no longer suggests the kit does the escaping (checked in a real app with a `</script>` string).
|
|
15
|
+
- Compatibility: "Tested with" versions instead of an unenforced "1.131 or later", and why there is no TanStack peer dependency.
|
|
16
|
+
- `checkProduct()`: the exact error and warning rules, including that a missing offer is an error only for merchant listings or when there is no rating or review.
|
|
17
|
+
- Product schema is described as following Google's rules, not as a promise of rich results.
|
|
18
|
+
- Documented: `index: false` throws `RangeError` past the limit, page 1 of an empty source is an empty urlset, the standalone `seo()` throws on relative URLs without `siteUrl`, and `og:title` is the untemplated title.
|
|
19
|
+
|
|
20
|
+
## [0.1.0] - 2026-10-23
|
|
21
|
+
|
|
22
|
+
First release.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
- `defineSeo()` / `seo()`: typed `head()` builder for TanStack Start and Router (title templates, description, canonical, Open Graph, Twitter cards, robots, hreflang alternates, article tags, JSON-LD).
|
|
26
|
+
- `jsonLd`: Organization, WebSite (sitelinks search box), Article/BlogPosting, BreadcrumbList, FAQPage, Person and `@graph` builders.
|
|
27
|
+
- `createSitemap()`: dynamic `sitemap.xml` from Start server routes, with automatic sitemap index and paginated sources for large sites; `routesFromTree()` for static routes; `sitemapXml()` and `sitemapIndexXml()`.
|
|
28
|
+
- `createRobotsHandler()` and `robotsTxt()`.
|
|
29
|
+
- `@amjadiqbal/start-seo-kit/commerce`: Product, Offer, AggregateOffer, AggregateRating, Review, shipping and return-policy JSON-LD, Open Graph `product:*` tags, `productSeo()`, `productSitemapEntries()` and `checkProduct()` against Google's product rich-result rules.
|
|
30
|
+
- `@amjadiqbal/start-seo-kit/og`: Open Graph image route (`createOgImageHandler()`, `renderOgImage()`, `ogCard()`) on satori and resvg-wasm, as optional peers.
|
|
31
|
+
|
|
32
|
+
### Tested
|
|
33
|
+
- Unit tests: 44 (Vitest), including a real satori + resvg-wasm PNG.
|
|
34
|
+
- A real TanStack Start 1.169.3 app (React, Vite 8, Node) installing the packed tarball, in dev and in a production build (`vite build` + `vite preview`): server-rendered head tags on every route type, client-side navigation updates, sitemap index and child sitemaps, robots.txt, and OG image PNGs.
|
|
35
|
+
|
|
36
|
+
[0.1.1]: https://github.com/amjadiqbal/start-seo-kit/releases/tag/v0.1.1
|
|
37
|
+
[0.1.0]: https://github.com/amjadiqbal/start-seo-kit/releases/tag/v0.1.0
|
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Amjad Iqbal
|
|
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,339 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="art/banner-dark.png">
|
|
4
|
+
<img alt="Start SEO Kit: meta tags, sitemaps, JSON-LD and OG images for TanStack Start" src="art/banner-light.png" width="100%">
|
|
5
|
+
</picture>
|
|
6
|
+
</p>
|
|
2
7
|
|
|
3
|
-
|
|
8
|
+
# Start SEO Kit: meta tags, sitemaps, robots.txt, JSON-LD and OG images for TanStack Start
|
|
9
|
+
|
|
10
|
+
Typed SEO helpers for [TanStack Start](https://tanstack.com/start) and TanStack Router: a `head()` builder with title templates, canonical URLs, Open Graph, Twitter cards, robots and hreflang; a dynamic `sitemap.xml` (with a sitemap index for large sites) and `robots.txt` served from Start server routes; typed JSON-LD structured data; e-commerce Product schema that follows Google's product structured-data rules; and an optional Open Graph image route.
|
|
11
|
+
|
|
12
|
+
[](https://www.npmjs.com/package/@amjadiqbal/start-seo-kit)
|
|
13
|
+
[](https://bundlephobia.com/package/@amjadiqbal/start-seo-kit)
|
|
14
|
+
[](LICENSE.md)
|
|
15
|
+
|
|
16
|
+
It builds on what Start already gives you (the route `head()` option and server routes) instead of replacing it: every helper returns the plain objects and `Response`s Start expects, so you can mix them with your own tags. No runtime dependencies, no network calls, no telemetry.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm i @amjadiqbal/start-seo-kit
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
For the optional OG image route, also install `satori` and `@resvg/resvg-wasm`:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm i satori @resvg/resvg-wasm
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Meta tags: `defineSeo()`
|
|
31
|
+
|
|
32
|
+
Define your site once:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
// src/seo.ts
|
|
36
|
+
import { defineSeo } from '@amjadiqbal/start-seo-kit'
|
|
37
|
+
|
|
38
|
+
export const seo = defineSeo({
|
|
39
|
+
url: 'https://acme.dev',
|
|
40
|
+
name: 'Acme',
|
|
41
|
+
titleTemplate: '%s | Acme',
|
|
42
|
+
defaultTitle: 'Acme: rockets for everyone',
|
|
43
|
+
description: 'Rockets, delivered.',
|
|
44
|
+
image: { url: '/og.png', width: 1200, height: 630, alt: 'Acme' },
|
|
45
|
+
locale: 'en_US',
|
|
46
|
+
twitter: { site: '@acme' },
|
|
47
|
+
})
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Then call it from any route's `head()`:
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
// src/routes/posts/$slug.tsx
|
|
54
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
55
|
+
import { jsonLd } from '@amjadiqbal/start-seo-kit'
|
|
56
|
+
import { seo } from '../../seo'
|
|
57
|
+
|
|
58
|
+
export const Route = createFileRoute('/posts/$slug')({
|
|
59
|
+
loader: ({ params }) => fetchPost(params.slug),
|
|
60
|
+
head: ({ loaderData: post }) =>
|
|
61
|
+
seo({
|
|
62
|
+
title: post?.title,
|
|
63
|
+
description: post?.excerpt,
|
|
64
|
+
path: `/posts/${post?.slug}`, // canonical link and og:url
|
|
65
|
+
image: `/og/${post?.slug}`, // the OG image route below
|
|
66
|
+
article: { publishedTime: post?.publishedAt, section: 'Blog' },
|
|
67
|
+
jsonLd: jsonLd.article({ type: 'BlogPosting', headline: post?.title ?? '', url: seo.url(`/posts/${post?.slug}`) }),
|
|
68
|
+
}),
|
|
69
|
+
component: PostPage,
|
|
70
|
+
})
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
That one call produces `<title>Hello | Acme</title>`, the description, `og:title`, `og:description`, `og:type`, `og:url`, `og:site_name`, `og:locale`, `og:image`, `article:*`, the `twitter:*` card tags, a `<link rel="canonical">` and a JSON-LD `<script>`. All URLs are made absolute.
|
|
74
|
+
|
|
75
|
+
Need more? `seo()` returns `{ meta, links }`, so spread it and add your own: `head: () => ({ ...seo({ title: 'About' }), scripts: [...] })`.
|
|
76
|
+
|
|
77
|
+
There is also a standalone `seo()` export for one-off use without `defineSeo()`. It has no site URL to resolve against, so it throws on a relative `path`, `canonical` or `image` unless you pass `siteUrl`.
|
|
78
|
+
|
|
79
|
+
| Option | What it does |
|
|
80
|
+
|---|---|
|
|
81
|
+
| `title`, `titleTemplate` | Page title through the site template (`%s \| Acme`); `titleTemplate: false` uses the title as is. `og:title` and `twitter:title` get the title **without** the template; `socialTitle` overrides them. |
|
|
82
|
+
| `description` | `description`, `og:description` and `twitter:description`. |
|
|
83
|
+
| `path` / `canonical` | Canonical link and `og:url`. `canonical: false` drops the link. |
|
|
84
|
+
| `image` | Share image (string or `{ url, width, height, alt, type }`); `false` drops the site default. |
|
|
85
|
+
| `noindex`, `robots` | `<meta name="robots">`: `noindex: true`, or `{ index, follow, maxImagePreview, maxSnippet, ... }`, or a string. |
|
|
86
|
+
| `alternates` | hreflang links: `{ en: '/en/about', de: '/de/about', 'x-default': '/about' }`. |
|
|
87
|
+
| `type`, `article` | `og:type` (default `website`, or `article` when `article` is set) and `article:published_time` and friends. |
|
|
88
|
+
| `twitter` | `card`, `site`, `creator` (merged with the site's). |
|
|
89
|
+
| `jsonLd` | One or more JSON-LD objects. |
|
|
90
|
+
| `meta`, `links` | Extra tags, appended as given. |
|
|
91
|
+
|
|
92
|
+
Set `trailingSlash: 'always'` in `defineSeo` if your router uses it, so canonical URLs match.
|
|
93
|
+
|
|
94
|
+
### How Router merges tags (worth knowing)
|
|
95
|
+
|
|
96
|
+
TanStack Router merges `head()` from every matched route. For `meta`, the deepest route wins per `name`/`property`, and tags a child route doesn't set are inherited from its parents. `links` are not merged: every route's links are rendered. So:
|
|
97
|
+
|
|
98
|
+
- Call `seo()` in the root route for site-wide defaults **without** `path`, and pass `path` only in leaf routes. That way there is exactly one canonical link.
|
|
99
|
+
- If the root sets an image with `width`/`height` and a child sets a different image without them, the child inherits the root's dimensions. Give child images their dimensions too, or set images only in leaf routes.
|
|
100
|
+
- Router keeps one tag per property, so only one `og:image` and one `article:author` survive per page. The builder emits one of each.
|
|
101
|
+
|
|
102
|
+
## Structured data: `jsonLd`
|
|
103
|
+
|
|
104
|
+
Typed builders for common schema.org types. Each returns a plain JSON-LD object; pass it to `seo({ jsonLd })`. The kit adds it to `meta` as `{ 'script:ld+json': object }` and does no serialising itself: TanStack Router turns it into `<script type="application/ld+json">` and HTML-escapes it (checked in a real app: a `</script>` inside a JSON-LD string does not end the script).
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import { jsonLd } from '@amjadiqbal/start-seo-kit'
|
|
108
|
+
|
|
109
|
+
jsonLd.organization({ name: 'Acme', url: 'https://acme.dev', logo: 'https://acme.dev/logo.png', sameAs: ['https://x.com/acme'] })
|
|
110
|
+
jsonLd.website({ name: 'Acme', url: 'https://acme.dev', searchUrlTemplate: 'https://acme.dev/search?q={search_term_string}' })
|
|
111
|
+
jsonLd.article({ type: 'BlogPosting', headline: 'Hello', datePublished: new Date(), author: 'Ana' })
|
|
112
|
+
jsonLd.breadcrumbs([{ name: 'Home', url: 'https://acme.dev/' }, { name: 'Blog', url: 'https://acme.dev/blog' }])
|
|
113
|
+
jsonLd.faq([{ question: 'Does it fly?', answer: 'Yes.' }])
|
|
114
|
+
jsonLd.person({ name: 'Ana', url: 'https://acme.dev/team/ana' })
|
|
115
|
+
jsonLd.graph(org, site) // several nodes in one @graph document
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## E-commerce: `@amjadiqbal/start-seo-kit/commerce`
|
|
119
|
+
|
|
120
|
+
Product pages need more than an Article: Google's product snippets and merchant listings read `Product` JSON-LD with an `Offer` (or `AggregateOffer`), ratings, reviews, shipping and a return policy. The `commerce` entry point builds all of it from one typed object, plus the Open Graph `product:*` tags.
|
|
121
|
+
|
|
122
|
+
```tsx
|
|
123
|
+
// src/routes/p/$slug.tsx
|
|
124
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
125
|
+
import { productSeo, type ProductInput } from '@amjadiqbal/start-seo-kit/commerce'
|
|
126
|
+
import { seo } from '../../seo'
|
|
127
|
+
|
|
128
|
+
export const Route = createFileRoute('/p/$slug')({
|
|
129
|
+
loader: ({ params }) => fetchProduct(params.slug),
|
|
130
|
+
head: ({ loaderData: p }) => {
|
|
131
|
+
if (!p) return {}
|
|
132
|
+
const product: ProductInput = {
|
|
133
|
+
name: p.name,
|
|
134
|
+
description: p.summary,
|
|
135
|
+
image: p.images, // absolute URLs
|
|
136
|
+
url: seo.url(`/p/${p.slug}`),
|
|
137
|
+
sku: p.sku,
|
|
138
|
+
gtin: p.gtin,
|
|
139
|
+
brand: p.brand,
|
|
140
|
+
offers: {
|
|
141
|
+
price: p.price, // 119.99, no currency symbol
|
|
142
|
+
currency: 'USD',
|
|
143
|
+
availability: p.stock > 0 ? 'InStock' : 'OutOfStock',
|
|
144
|
+
condition: 'NewCondition',
|
|
145
|
+
shipping: { rate: 0, currency: 'USD', country: 'US', handlingDays: [0, 1], transitDays: [2, 5] },
|
|
146
|
+
returnPolicy: { country: 'US', category: 'finite', days: 30, fees: 'free', method: ['mail'] },
|
|
147
|
+
},
|
|
148
|
+
rating: { value: p.rating, reviewCount: p.reviewCount },
|
|
149
|
+
}
|
|
150
|
+
return seo({ title: p.name, description: p.summary, path: `/p/${p.slug}`, image: p.images[0], ...productSeo(product) })
|
|
151
|
+
},
|
|
152
|
+
component: ProductPage,
|
|
153
|
+
})
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`productSeo()` sets `og:type` to `product`, adds `product:price:amount`, `product:price:currency`, `product:availability`, `product:condition`, `product:brand` and `product:retailer_item_id`, and the Product JSON-LD.
|
|
157
|
+
|
|
158
|
+
- `offers` takes one offer, an array (several sellers) or a price range `{ lowPrice, highPrice, currency, offerCount }` (an `AggregateOffer`; Google wants a plain `Offer` for merchant listings and price-drop badges).
|
|
159
|
+
- `reviews: [{ author, rating, body, positiveNotes, negativeNotes }]` adds `Review` nodes, including pros and cons for editorial reviews.
|
|
160
|
+
- `returnPolicy` is either `{ country, category: 'finite' | 'unlimited' | 'notPermitted', days, fees, method }` or just `{ url }`. `returnPolicyLd()` and `shippingLd()` are exported if you want them on your Organization instead.
|
|
161
|
+
- `productJsonLd()`, `offerLd()`, `aggregateOfferLd()`, `aggregateRatingLd()` and `reviewLd()` return the individual nodes. `jsonLd.breadcrumbs()` is re-exported for category trails.
|
|
162
|
+
- `productSitemapEntries(products, { path, lastmod, images })` turns your catalogue into a `createSitemap` source with `<image:image>` entries.
|
|
163
|
+
|
|
164
|
+
### Check a product against Google's rules
|
|
165
|
+
|
|
166
|
+
`checkProduct(product, options)` returns `{ errors, warnings }` based on Google's product snippet, merchant listing, shipping and return-policy documentation (checked 2026-10-22). It never throws.
|
|
167
|
+
|
|
168
|
+
Errors (the page isn't eligible for that result):
|
|
169
|
+
|
|
170
|
+
- `name` is empty.
|
|
171
|
+
- None of `offers`, `rating` or `reviews` is set (product snippets need one of them).
|
|
172
|
+
- With `{ merchantListing: true }` only: no `image`, or no `offers`.
|
|
173
|
+
- A price, `lowPrice`, `highPrice` or shipping rate that isn't a plain non-negative number (e.g. `'$1,000'`).
|
|
174
|
+
- A currency that isn't three upper-case letters (`usd` fails).
|
|
175
|
+
- Shipping in a different currency from its offer, or shipping without `handlingDays` or `transitDays`.
|
|
176
|
+
- A `finite` return policy without `days`.
|
|
177
|
+
- A `rating` without `count` or `reviewCount`, or a value outside `worst`..`best` (default 1..5).
|
|
178
|
+
- A review with an empty `author`.
|
|
179
|
+
|
|
180
|
+
Warnings (recommended, not required): `offers` missing while there are ratings or reviews; an `AggregateOffer` when checking for merchant listings; an offer without `availability`; a review with only one pros/cons statement; no `brand`; none of `gtin`, `mpn` or `sku`; no `description`.
|
|
181
|
+
|
|
182
|
+
So a product with a rating but no offer passes the snippet check with a warning, and fails only with `merchantListing: true`. Run it in a unit test over your catalogue:
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
for (const p of await listProducts()) {
|
|
186
|
+
expect(checkProduct(toProductInput(p), { merchantListing: true }).errors).toEqual([])
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
It checks the rules that can be checked offline. Google's Rich Results Test is still the final word.
|
|
191
|
+
|
|
192
|
+
## Dynamic sitemap: `createSitemap()`
|
|
193
|
+
|
|
194
|
+
Start's built-in sitemap only covers prerendered pages. `createSitemap` serves one at request time from a server route, with static pages read from your route tree and dynamic pages from your data:
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
// src/sitemap.ts
|
|
198
|
+
import { createSitemap, routesFromTree } from '@amjadiqbal/start-seo-kit'
|
|
199
|
+
import { routeTree } from './routeTree.gen'
|
|
200
|
+
|
|
201
|
+
export const sitemap = createSitemap({
|
|
202
|
+
siteUrl: 'https://acme.dev',
|
|
203
|
+
sources: {
|
|
204
|
+
// a function, so the route tree is read per request (this module is imported by a route in routeTree.gen)
|
|
205
|
+
pages: () => routesFromTree(routeTree, { exclude: ['/login', /^\/admin/] }),
|
|
206
|
+
posts: async () => (await db.posts.list()).map((p) => ({ loc: `/posts/${p.slug}`, lastmod: p.updatedAt })),
|
|
207
|
+
},
|
|
208
|
+
})
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
// src/routes/sitemap[.]xml.ts
|
|
213
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
214
|
+
import { sitemap } from '../sitemap'
|
|
215
|
+
|
|
216
|
+
export const Route = createFileRoute('/sitemap.xml')({
|
|
217
|
+
server: { handlers: { GET: sitemap.index } },
|
|
218
|
+
})
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
// src/routes/sitemaps/$name.ts (child sitemaps, used once the site outgrows one file)
|
|
223
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
224
|
+
import { sitemap } from '../../sitemap'
|
|
225
|
+
|
|
226
|
+
export const Route = createFileRoute('/sitemaps/$name')({
|
|
227
|
+
server: { handlers: { GET: sitemap.page } },
|
|
228
|
+
})
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
- While every URL fits in one file (50,000 by default, `maxUrlsPerSitemap`), `/sitemap.xml` is a plain `<urlset>`. Past that it becomes a `<sitemapindex>` pointing at `/sitemaps/pages-1.xml`, `/sitemaps/posts-1.xml`, `/sitemaps/posts-2.xml` and so on. `index: true` always serves an index.
|
|
232
|
+
- **Cost per request:** an array or function source is loaded in full once per request: once for `/sitemap.xml`, and again for each child sitemap request (which then keeps only its own chunk). That's fine for thousands of rows. For large tables, give the source `count()` and `entries({ offset, limit })` instead: the index then calls only `count()`, and each child sitemap loads only its own range: `posts: { count: () => db.posts.count(), entries: ({ offset, limit }) => db.posts.page(offset, limit) }`. Put a CDN cache in front of the sitemap routes either way (the default `Cache-Control` is an hour).
|
|
233
|
+
- `index: false` always serves a single `<urlset>` and throws a `RangeError` (a 500 response) once the total passes `maxUrlsPerSitemap`.
|
|
234
|
+
- A child sitemap past the last chunk returns 404; page 1 of an empty source returns an empty `<urlset>`, so links from the index never break.
|
|
235
|
+
- Entries take `loc`, `lastmod`, `changefreq`, `priority`, hreflang `alternates` and `images`.
|
|
236
|
+
- `routesFromTree` lists parameter-free page routes. It skips dynamic routes (`$id`, `$`), server-only routes, paths with a file extension and routes with `staticData: { sitemap: false }`. `staticData: { sitemap: { priority: 0.8 } }` sets per-route values. If you use `staticData` with TypeScript, declare the field once:
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
declare module '@tanstack/react-router' {
|
|
240
|
+
interface StaticDataRouteOption {
|
|
241
|
+
sitemap?: false | { priority?: number; changefreq?: 'daily' | 'weekly' | 'monthly' }
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
`sitemapXml()` and `sitemapIndexXml()` are exported too, if you'd rather build the response yourself.
|
|
247
|
+
|
|
248
|
+
## robots.txt: `createRobotsHandler()`
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
// src/routes/robots[.]txt.ts
|
|
252
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
253
|
+
import { createRobotsHandler } from '@amjadiqbal/start-seo-kit'
|
|
254
|
+
|
|
255
|
+
export const Route = createFileRoute('/robots.txt')({
|
|
256
|
+
server: {
|
|
257
|
+
handlers: {
|
|
258
|
+
GET: createRobotsHandler(({ request }) => ({
|
|
259
|
+
siteUrl: 'https://acme.dev',
|
|
260
|
+
// keep preview deployments out of search results
|
|
261
|
+
disallowAll: new URL(request.url).hostname !== 'acme.dev',
|
|
262
|
+
rules: [{ userAgent: '*', allow: '/', disallow: ['/admin', '/api'] }],
|
|
263
|
+
sitemaps: ['/sitemap.xml'],
|
|
264
|
+
})),
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
})
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
Delete `public/robots.txt` if you have one, since a static file is served before the route.
|
|
271
|
+
|
|
272
|
+
## Open Graph images: `@amjadiqbal/start-seo-kit/og` (optional)
|
|
273
|
+
|
|
274
|
+
Renders a 1200×630 PNG per page with [satori](https://github.com/vercel/satori) and [resvg-wasm](https://github.com/yisibl/resvg-js). It lives in its own entry point, so the core stays dependency-free.
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
// src/routes/og/$slug.ts
|
|
278
|
+
import { readFile } from 'node:fs/promises'
|
|
279
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
280
|
+
import { createOgImageHandler, ogCard } from '@amjadiqbal/start-seo-kit/og'
|
|
281
|
+
|
|
282
|
+
const og = createOgImageHandler({
|
|
283
|
+
// satori needs at least one font file (TTF, OTF or WOFF, not WOFF2); loaded once
|
|
284
|
+
fonts: async () => [{ name: 'Inter', weight: 700, data: await readFile('src/assets/Inter-Bold.ttf') }],
|
|
285
|
+
render: async ({ params }) => {
|
|
286
|
+
const post = await fetchPost(params.slug)
|
|
287
|
+
if (!post) return null // 404
|
|
288
|
+
return ogCard({ title: post.title, description: post.excerpt, eyebrow: 'Acme blog', footer: 'acme.dev', fontFamily: 'Inter' })
|
|
289
|
+
},
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
export const Route = createFileRoute('/og/$slug')({
|
|
293
|
+
server: { handlers: { GET: og } },
|
|
294
|
+
})
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
- `ogCard()` is a ready-made design (dark background, emerald accent, all colours configurable). You can return your own JSX instead: any element satori accepts.
|
|
298
|
+
- Tested on Node, where the resvg `.wasm` is read from `node_modules` automatically. If your runtime can't read `node_modules`, the `wasm` option takes the module or its bytes; that path is not tested yet.
|
|
299
|
+
- `format: 'svg'` skips resvg and returns SVG (fine for testing; most social sites want PNG).
|
|
300
|
+
- Responses are cached for a day (`cacheControl` to change).
|
|
301
|
+
- `renderOgImage(element, options)` is exported for use outside a route, e.g. at build time.
|
|
302
|
+
|
|
303
|
+
## Compatibility
|
|
304
|
+
|
|
305
|
+
- Tested with `@tanstack/react-start` 1.169.3 and `@tanstack/react-router` 1.170.45. The server-route helpers need Start's `server.handlers` route option; older versions aren't tested. The package has no TanStack peer dependency because it imports nothing from TanStack: it returns plain objects and `Response`s.
|
|
306
|
+
- Node 20 or later. ESM only.
|
|
307
|
+
- React (`@tanstack/react-start`). Other Start adapters are not tested yet.
|
|
308
|
+
|
|
309
|
+
## What's tested
|
|
310
|
+
|
|
311
|
+
- Unit tests (Vitest) for every helper: tag output, URL handling, XML escaping in sitemaps, sitemap chunking, the index and how often sources are loaded, route-tree walking, robots rules, the commerce schema and `checkProduct` rules, and a real PNG rendered through satori and resvg-wasm.
|
|
312
|
+
- Type tests: `seo()` output assigned to a Router route's `head()` with no casts.
|
|
313
|
+
- A real TanStack Start app installing the packed tarball: head tags in the server-rendered HTML and after client-side navigation, JSON-LD escaping by Router, `/sitemap.xml`, child sitemaps, `/robots.txt` and an OG image route, in dev and in a production build, checked in a browser.
|
|
314
|
+
|
|
315
|
+
Not tested: Start adapters other than React; runtimes other than Node for the OG route; and live Google indexing (the commerce output follows Google's documentation, but whether a page gets a rich result is Google's call).
|
|
316
|
+
|
|
317
|
+
## Coming from hand-written head tags or `super-sitemap`
|
|
318
|
+
|
|
319
|
+
- **Hand-written `head()`**: replace each `meta` array with one `seo({...})` call. Your existing `scripts` and `styles` stay as they are.
|
|
320
|
+
- **Start's prerender sitemap**: keep it for fully static sites. Use `createSitemap` when pages come from a database or the site isn't prerendered, and turn the built-in `sitemap` option off so the two don't both write `/sitemap.xml`.
|
|
321
|
+
- **`super-sitemap`**: its route list maps to `() => routesFromTree(routeTree)` plus one source per dynamic route (`posts: () => slugs.map(...)`). Chunking into a sitemap index is automatic.
|
|
322
|
+
|
|
323
|
+
## Support
|
|
324
|
+
|
|
325
|
+
- 🐛 **Bug or feature request**: [open an issue](https://github.com/amjadiqbal/start-seo-kit/issues)
|
|
326
|
+
- 🔒 **Security issue**: please report privately via GitHub's security tab
|
|
327
|
+
|
|
328
|
+
## Need this built into your app, or something custom?
|
|
329
|
+
|
|
330
|
+
I build and fix TanStack Start, Router and Query apps, including SEO, migrations and performance work.
|
|
331
|
+
[Get in touch](https://amjad.com.pk/contact?utm_source=github&utm_medium=readme&utm_campaign=start-seo-kit).
|
|
332
|
+
|
|
333
|
+
## More packages
|
|
334
|
+
|
|
335
|
+
Part of a family of open-source packages. See [all of them](https://github.com/amjadiqbal?tab=repositories).
|
|
336
|
+
|
|
337
|
+
## License
|
|
338
|
+
|
|
339
|
+
MIT. See [LICENSE.md](LICENSE.md).
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { C as jsonLd, a as MetaTag, c as SeoInput, v as JsonLdObject } from "./head-f4tZr5jl.js";
|
|
2
|
+
import { o as SitemapEntry } from "./sitemap-BiQmwT37.js";
|
|
3
|
+
export type Availability = 'InStock' | 'OutOfStock' | 'PreOrder' | 'PreSale' | 'BackOrder' | 'Discontinued' | 'LimitedAvailability' | 'InStoreOnly' | 'OnlineOnly' | 'SoldOut';
|
|
4
|
+
export type ItemCondition = 'NewCondition' | 'UsedCondition' | 'RefurbishedCondition' | 'DamagedCondition';
|
|
5
|
+
/** A price without currency symbols or thousands separators: `39.99` or `'39.99'`. */
|
|
6
|
+
export type Price = number | string;
|
|
7
|
+
export interface ShippingInput {
|
|
8
|
+
/** Shipping cost in `currency`; `0` for free shipping. */
|
|
9
|
+
rate: Price;
|
|
10
|
+
/** ISO 4217 code; must match the offer's currency. */
|
|
11
|
+
currency: string;
|
|
12
|
+
/** ISO 3166-1 alpha-2 country code(s). */
|
|
13
|
+
country: string | string[];
|
|
14
|
+
/** ISO 3166-2 subdivision codes without the country prefix (US, AU, JP only), e.g. `['NY', 'CA']`. */
|
|
15
|
+
regions?: string[];
|
|
16
|
+
/** Business days between order and dispatch, `[min, max]`. */
|
|
17
|
+
handlingDays?: [number, number];
|
|
18
|
+
/** Business days in transit, `[min, max]`. */
|
|
19
|
+
transitDays?: [number, number];
|
|
20
|
+
}
|
|
21
|
+
export type ReturnPolicyInput = {
|
|
22
|
+
/** ISO 3166-1 alpha-2 country code(s) the policy applies to (up to 50). */
|
|
23
|
+
country: string | string[];
|
|
24
|
+
/** `finite` needs `days`. */
|
|
25
|
+
category: 'finite' | 'unlimited' | 'notPermitted';
|
|
26
|
+
days?: number;
|
|
27
|
+
fees?: 'free' | 'customer' | {
|
|
28
|
+
amount: Price;
|
|
29
|
+
currency: string;
|
|
30
|
+
};
|
|
31
|
+
method?: Array<'mail' | 'store' | 'kiosk'>;
|
|
32
|
+
/** Optional link to the policy page. */
|
|
33
|
+
url?: string;
|
|
34
|
+
} | {
|
|
35
|
+
/** Only a link to the return policy page (Google's option B). */
|
|
36
|
+
url: string;
|
|
37
|
+
};
|
|
38
|
+
export interface OfferInput {
|
|
39
|
+
price: Price;
|
|
40
|
+
/** ISO 4217, e.g. `USD`. */
|
|
41
|
+
currency: string;
|
|
42
|
+
availability?: Availability;
|
|
43
|
+
condition?: ItemCondition;
|
|
44
|
+
url?: string;
|
|
45
|
+
priceValidUntil?: string | Date;
|
|
46
|
+
sku?: string;
|
|
47
|
+
seller?: string;
|
|
48
|
+
shipping?: ShippingInput | ShippingInput[];
|
|
49
|
+
returnPolicy?: ReturnPolicyInput;
|
|
50
|
+
}
|
|
51
|
+
export interface AggregateOfferInput {
|
|
52
|
+
lowPrice: Price;
|
|
53
|
+
highPrice?: Price;
|
|
54
|
+
currency: string;
|
|
55
|
+
offerCount?: number;
|
|
56
|
+
availability?: Availability;
|
|
57
|
+
}
|
|
58
|
+
export interface RatingInput {
|
|
59
|
+
/** Average rating, e.g. 4.4. */
|
|
60
|
+
value: number;
|
|
61
|
+
/** Number of ratings; give this or `reviewCount` (or both). */
|
|
62
|
+
count?: number;
|
|
63
|
+
reviewCount?: number;
|
|
64
|
+
best?: number;
|
|
65
|
+
worst?: number;
|
|
66
|
+
}
|
|
67
|
+
export interface ReviewInput {
|
|
68
|
+
/** A real person's or team's name (Google rejects names like "50% off"). */
|
|
69
|
+
author: string;
|
|
70
|
+
/** Rating out of `best` (default 5). */
|
|
71
|
+
rating: number;
|
|
72
|
+
best?: number;
|
|
73
|
+
worst?: number;
|
|
74
|
+
body?: string;
|
|
75
|
+
name?: string;
|
|
76
|
+
datePublished?: string | Date;
|
|
77
|
+
/** Pros and cons for editorial reviews (Google needs two statements in total). */
|
|
78
|
+
positiveNotes?: string[];
|
|
79
|
+
negativeNotes?: string[];
|
|
80
|
+
}
|
|
81
|
+
export interface ProductInput {
|
|
82
|
+
name: string;
|
|
83
|
+
description?: string;
|
|
84
|
+
/** One or more absolute image URLs (required for merchant listings). */
|
|
85
|
+
image?: string | string[];
|
|
86
|
+
url?: string;
|
|
87
|
+
sku?: string;
|
|
88
|
+
mpn?: string;
|
|
89
|
+
/** GTIN-8/12/13/14 or ISBN; emitted as `gtin`. */
|
|
90
|
+
gtin?: string;
|
|
91
|
+
brand?: string;
|
|
92
|
+
category?: string;
|
|
93
|
+
color?: string;
|
|
94
|
+
size?: string;
|
|
95
|
+
material?: string;
|
|
96
|
+
/** Product-group ID shared by variants. */
|
|
97
|
+
groupId?: string;
|
|
98
|
+
/** One offer, several (e.g. sellers), or a price range. */
|
|
99
|
+
offers?: OfferInput | OfferInput[] | AggregateOfferInput;
|
|
100
|
+
rating?: RatingInput;
|
|
101
|
+
reviews?: ReviewInput[];
|
|
102
|
+
id?: string;
|
|
103
|
+
}
|
|
104
|
+
/** Product JSON-LD (with Offer or AggregateOffer, ratings, reviews, shipping and returns) for Google product rich results. */
|
|
105
|
+
export declare function productJsonLd(p: ProductInput): JsonLdObject;
|
|
106
|
+
/** One `Offer`. */
|
|
107
|
+
export declare function offerLd(o: OfferInput): JsonLdObject;
|
|
108
|
+
/** An `AggregateOffer` (price range across sellers or variants). */
|
|
109
|
+
export declare function aggregateOfferLd(o: AggregateOfferInput): JsonLdObject;
|
|
110
|
+
export declare function aggregateRatingLd(r: RatingInput): JsonLdObject;
|
|
111
|
+
export declare function reviewLd(r: ReviewInput): JsonLdObject;
|
|
112
|
+
/** `OfferShippingDetails`. */
|
|
113
|
+
export declare function shippingLd(s: ShippingInput): JsonLdObject;
|
|
114
|
+
/** `MerchantReturnPolicy`. Also usable on an Organization (`hasMerchantReturnPolicy`). */
|
|
115
|
+
export declare function returnPolicyLd(r: ReturnPolicyInput): JsonLdObject;
|
|
116
|
+
/** Open Graph product tags: `product:price:amount`, `product:price:currency`, availability, condition, brand, item id. */
|
|
117
|
+
export declare function productOpenGraph(p: ProductInput): MetaTag[];
|
|
118
|
+
/**
|
|
119
|
+
* Everything a product page needs, as `seo()` options: `og:type` product, the product Open Graph tags
|
|
120
|
+
* and the Product JSON-LD. Spread it into your call:
|
|
121
|
+
*
|
|
122
|
+
* ```ts
|
|
123
|
+
* head: ({ loaderData: p }) => seo({ title: p.name, path: `/p/${p.slug}`, image: p.images[0], ...productSeo(p) })
|
|
124
|
+
* ```
|
|
125
|
+
*/
|
|
126
|
+
export declare function productSeo(p: ProductInput): Pick<SeoInput, 'type' | 'meta' | 'jsonLd'>;
|
|
127
|
+
export interface ProductCheck {
|
|
128
|
+
/** Missing required properties: the page is not eligible for that rich result. */
|
|
129
|
+
errors: string[];
|
|
130
|
+
/** Missing recommended properties or values Google may reject. */
|
|
131
|
+
warnings: string[];
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Check a product against Google's product snippet and merchant listing requirements
|
|
135
|
+
* (as documented on 2026-10-22). Use it in tests or development; it does not throw.
|
|
136
|
+
*/
|
|
137
|
+
export declare function checkProduct(p: ProductInput, options?: {
|
|
138
|
+
merchantListing?: boolean;
|
|
139
|
+
}): ProductCheck;
|
|
140
|
+
export interface ProductSitemapOptions<T> {
|
|
141
|
+
/** Page path of a product. */
|
|
142
|
+
path: (item: T) => string;
|
|
143
|
+
lastmod?: (item: T) => string | Date | undefined;
|
|
144
|
+
/** Image URLs for the sitemap's `<image:image>` entries. */
|
|
145
|
+
images?: (item: T) => string[] | undefined;
|
|
146
|
+
changefreq?: SitemapEntry['changefreq'];
|
|
147
|
+
priority?: number;
|
|
148
|
+
}
|
|
149
|
+
/** Map your products to sitemap entries (with image entries) for a `createSitemap` source. */
|
|
150
|
+
export declare function productSitemapEntries<T>(items: Iterable<T>, options: ProductSitemapOptions<T>): SitemapEntry[];
|
|
151
|
+
export { jsonLd };
|