@amjadiqbal/start-seo-kit 0.1.0 → 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 +15 -0
- package/README.md +28 -8
- package/dist/commerce.d.ts +2 -2
- package/dist/{head-D2pPoGNP.d.ts → head-f4tZr5jl.d.ts} +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +22 -13
- package/dist/og.d.ts +1 -1
- package/dist/{sitemap-DbEixSCi.d.ts → sitemap-BiQmwT37.d.ts} +4 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,20 @@
|
|
|
3
3
|
All notable changes to `@amjadiqbal/start-seo-kit` are documented here.
|
|
4
4
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [Semantic Versioning](https://semver.org/).
|
|
5
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
|
+
|
|
6
20
|
## [0.1.0] - 2026-10-23
|
|
7
21
|
|
|
8
22
|
First release.
|
|
@@ -19,4 +33,5 @@ First release.
|
|
|
19
33
|
- Unit tests: 44 (Vitest), including a real satori + resvg-wasm PNG.
|
|
20
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.
|
|
21
35
|
|
|
36
|
+
[0.1.1]: https://github.com/amjadiqbal/start-seo-kit/releases/tag/v0.1.1
|
|
22
37
|
[0.1.0]: https://github.com/amjadiqbal/start-seo-kit/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
# Start SEO Kit: meta tags, sitemaps, robots.txt, JSON-LD and OG images for TanStack Start
|
|
9
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
|
|
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
11
|
|
|
12
12
|
[](https://www.npmjs.com/package/@amjadiqbal/start-seo-kit)
|
|
13
13
|
[](https://bundlephobia.com/package/@amjadiqbal/start-seo-kit)
|
|
@@ -74,9 +74,11 @@ That one call produces `<title>Hello | Acme</title>`, the description, `og:title
|
|
|
74
74
|
|
|
75
75
|
Need more? `seo()` returns `{ meta, links }`, so spread it and add your own: `head: () => ({ ...seo({ title: 'About' }), scripts: [...] })`.
|
|
76
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
|
+
|
|
77
79
|
| Option | What it does |
|
|
78
80
|
|---|---|
|
|
79
|
-
| `title`, `titleTemplate` | Page title through the site template (`%s \| Acme`); `titleTemplate: false` uses the title as is. |
|
|
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. |
|
|
80
82
|
| `description` | `description`, `og:description` and `twitter:description`. |
|
|
81
83
|
| `path` / `canonical` | Canonical link and `og:url`. `canonical: false` drops the link. |
|
|
82
84
|
| `image` | Share image (string or `{ url, width, height, alt, type }`); `false` drops the site default. |
|
|
@@ -99,7 +101,7 @@ TanStack Router merges `head()` from every matched route. For `meta`, the deepes
|
|
|
99
101
|
|
|
100
102
|
## Structured data: `jsonLd`
|
|
101
103
|
|
|
102
|
-
Typed builders for common schema.org types. Each returns a JSON-LD object; pass it to `seo({ jsonLd })` and Router
|
|
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).
|
|
103
105
|
|
|
104
106
|
```ts
|
|
105
107
|
import { jsonLd } from '@amjadiqbal/start-seo-kit'
|
|
@@ -161,7 +163,23 @@ export const Route = createFileRoute('/p/$slug')({
|
|
|
161
163
|
|
|
162
164
|
### Check a product against Google's rules
|
|
163
165
|
|
|
164
|
-
`checkProduct(product,
|
|
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:
|
|
165
183
|
|
|
166
184
|
```ts
|
|
167
185
|
for (const p of await listProducts()) {
|
|
@@ -211,7 +229,9 @@ export const Route = createFileRoute('/sitemaps/$name')({
|
|
|
211
229
|
```
|
|
212
230
|
|
|
213
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.
|
|
214
|
-
- For
|
|
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.
|
|
215
235
|
- Entries take `loc`, `lastmod`, `changefreq`, `priority`, hreflang `alternates` and `images`.
|
|
216
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:
|
|
217
237
|
|
|
@@ -282,15 +302,15 @@ export const Route = createFileRoute('/og/$slug')({
|
|
|
282
302
|
|
|
283
303
|
## Compatibility
|
|
284
304
|
|
|
285
|
-
-
|
|
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.
|
|
286
306
|
- Node 20 or later. ESM only.
|
|
287
307
|
- React (`@tanstack/react-start`). Other Start adapters are not tested yet.
|
|
288
308
|
|
|
289
309
|
## What's tested
|
|
290
310
|
|
|
291
|
-
- Unit tests (Vitest) for every helper: tag output, URL handling, escaping, sitemap chunking and
|
|
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.
|
|
292
312
|
- Type tests: `seo()` output assigned to a Router route's `head()` with no casts.
|
|
293
|
-
- A real TanStack Start app installing the packed tarball: head tags in the server-rendered HTML, `/sitemap.xml`,
|
|
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.
|
|
294
314
|
|
|
295
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).
|
|
296
316
|
|
package/dist/commerce.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { C as jsonLd, a as MetaTag, c as SeoInput, v as JsonLdObject } from "./head-
|
|
2
|
-
import { o as SitemapEntry } from "./sitemap-
|
|
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
3
|
export type Availability = 'InStock' | 'OutOfStock' | 'PreOrder' | 'PreSale' | 'BackOrder' | 'Discontinued' | 'LimitedAvailability' | 'InStoreOnly' | 'OnlineOnly' | 'SoldOut';
|
|
4
4
|
export type ItemCondition = 'NewCondition' | 'UsedCondition' | 'RefurbishedCondition' | 'DamagedCondition';
|
|
5
5
|
/** A price without currency symbols or thousands separators: `39.99` or `'39.99'`. */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { g as TrailingSlash } from "./sitemap-
|
|
1
|
+
import { g as TrailingSlash } from "./sitemap-BiQmwT37.js";
|
|
2
2
|
/** Any JSON value allowed inside a JSON-LD document. */
|
|
3
3
|
type JsonLdValue = string | number | boolean | null | JsonLdValue[] | {
|
|
4
4
|
[key: string]: JsonLdValue | undefined;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { C as jsonLd, S as WebSiteInput, _ as FaqItem, a as MetaTag, b as OrganizationInput, c as SeoInput, d as TwitterInput, f as defineSeo, g as BreadcrumbItem, h as ArticleLdInput, i as LinkTag, l as SiteConfig, m as seo, n as HeadTags, o as RobotsInput, p as robotsContent, r as ImageInput, s as Seo, t as ArticleInput, u as TwitterCard, v as JsonLdObject, x as PersonInput, y as JsonLdValue } from "./head-
|
|
2
|
-
import { _ as absoluteUrl, a as ServerHandler, c as SitemapImage, d as UrlOptions, f as createSitemap, g as TrailingSlash, h as sitemapXml, i as RoutesFromTreeOptions, l as SitemapIndexEntry, m as sitemapIndexXml, n as CreateSitemapOptions, o as SitemapEntry, p as routesFromTree, r as HandlerContext, s as SitemapHandlers, t as ChangeFreq, u as SitemapSource } from "./sitemap-
|
|
1
|
+
import { C as jsonLd, S as WebSiteInput, _ as FaqItem, a as MetaTag, b as OrganizationInput, c as SeoInput, d as TwitterInput, f as defineSeo, g as BreadcrumbItem, h as ArticleLdInput, i as LinkTag, l as SiteConfig, m as seo, n as HeadTags, o as RobotsInput, p as robotsContent, r as ImageInput, s as Seo, t as ArticleInput, u as TwitterCard, v as JsonLdObject, x as PersonInput, y as JsonLdValue } from "./head-f4tZr5jl.js";
|
|
2
|
+
import { _ as absoluteUrl, a as ServerHandler, c as SitemapImage, d as UrlOptions, f as createSitemap, g as TrailingSlash, h as sitemapXml, i as RoutesFromTreeOptions, l as SitemapIndexEntry, m as sitemapIndexXml, n as CreateSitemapOptions, o as SitemapEntry, p as routesFromTree, r as HandlerContext, s as SitemapHandlers, t as ChangeFreq, u as SitemapSource } from "./sitemap-BiQmwT37.js";
|
|
3
3
|
type Awaitable<T> = T | Promise<T>;
|
|
4
4
|
interface RobotsRule {
|
|
5
5
|
userAgent: string | string[];
|
package/dist/index.js
CHANGED
|
@@ -291,11 +291,14 @@ function createSitemap(options) {
|
|
|
291
291
|
"Cache-Control": options.cacheControl ?? "public, max-age=3600"
|
|
292
292
|
};
|
|
293
293
|
for (const name of Object.keys(options.sources)) if (!/^[\w-]+$/.test(name)) throw new TypeError(`start-seo-kit: invalid sitemap source name "${name}"`);
|
|
294
|
-
const
|
|
295
|
-
const slice = async (src, offset, limit) =>
|
|
296
|
-
|
|
297
|
-
limit
|
|
298
|
-
|
|
294
|
+
const load = async (src) => Array.isArray(src) ? src : typeof src === "function" ? await src() : void 0;
|
|
295
|
+
const slice = async (src, offset, limit) => {
|
|
296
|
+
const loaded = await load(src);
|
|
297
|
+
return loaded ? loaded.slice(offset, offset + limit) : await src.entries({
|
|
298
|
+
offset,
|
|
299
|
+
limit
|
|
300
|
+
});
|
|
301
|
+
};
|
|
299
302
|
const all = async () => {
|
|
300
303
|
const out = [];
|
|
301
304
|
for (const src of Object.values(options.sources)) out.push(...await slice(src, 0, Number.MAX_SAFE_INTEGER));
|
|
@@ -304,18 +307,24 @@ function createSitemap(options) {
|
|
|
304
307
|
return {
|
|
305
308
|
entries: all,
|
|
306
309
|
async index() {
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
310
|
+
const sections = await Promise.all(Object.entries(options.sources).map(async ([name, src]) => {
|
|
311
|
+
const loaded = await load(src);
|
|
312
|
+
return {
|
|
313
|
+
name,
|
|
314
|
+
src,
|
|
315
|
+
loaded,
|
|
316
|
+
count: loaded ? loaded.length : await src.count()
|
|
317
|
+
};
|
|
318
|
+
}));
|
|
319
|
+
const total = sections.reduce((sum, s) => sum + s.count, 0);
|
|
313
320
|
if (!(options.index === true || options.index !== false && total > max)) {
|
|
314
321
|
if (total > max) throw new RangeError(`start-seo-kit: ${total} URLs exceed maxUrlsPerSitemap (${max}); enable index`);
|
|
315
|
-
|
|
322
|
+
const entries = [];
|
|
323
|
+
for (const s of sections) entries.push(...s.loaded ?? await slice(s.src, 0, s.count));
|
|
324
|
+
return new Response(sitemapXml(entries, options), { headers });
|
|
316
325
|
}
|
|
317
326
|
const children = [];
|
|
318
|
-
for (const
|
|
327
|
+
for (const { name, count: c, src } of sections) {
|
|
319
328
|
const lastmod = !Array.isArray(src) && typeof src === "object" && src.lastmod ? await src.lastmod() : void 0;
|
|
320
329
|
for (let i = 0; i < Math.max(1, Math.ceil(c / max)); i++) children.push({
|
|
321
330
|
loc: `${basePath}/${name}-${i + 1}.xml`,
|
package/dist/og.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as ServerHandler, r as HandlerContext } from "./sitemap-
|
|
1
|
+
import { a as ServerHandler, r as HandlerContext } from "./sitemap-BiQmwT37.js";
|
|
2
2
|
type Awaitable<T> = T | Promise<T>;
|
|
3
3
|
/** A satori element: a React element (JSX) or a plain `{ type, props }` object tree. */
|
|
4
4
|
export type OgElement = {
|
|
@@ -45,9 +45,10 @@ declare function sitemapIndexXml(sitemaps: Iterable<SitemapIndexEntry>, options:
|
|
|
45
45
|
/**
|
|
46
46
|
* Where the URLs of one sitemap section come from:
|
|
47
47
|
* - an array of entries,
|
|
48
|
-
* - a function returning them (sync or async, e.g. a database query),
|
|
49
|
-
*
|
|
50
|
-
*
|
|
48
|
+
* - a function returning them (sync or async, e.g. a database query), called once per request
|
|
49
|
+
* to `/sitemap.xml` and once per child-sitemap request, loading every entry each time, or
|
|
50
|
+
* - a paginated source with `count()` and `entries({ offset, limit })` for large sets:
|
|
51
|
+
* the index calls only `count()`, and each child sitemap loads only its own range.
|
|
51
52
|
*/
|
|
52
53
|
type SitemapSource = SitemapEntry[] | (() => Awaitable<SitemapEntry[]>) | {
|
|
53
54
|
count: () => Awaitable<number>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amjadiqbal/start-seo-kit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "SEO kit for TanStack Start: typed head() meta tags (title templates, canonical, Open Graph, Twitter cards, robots, hreflang), dynamic sitemap and robots.txt server routes, JSON-LD helpers, e-commerce Product schema and an optional Open Graph image route.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"tanstack",
|