@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 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 for Google product rich results; and an optional Open Graph image route.
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
  [![npm](https://img.shields.io/npm/v/@amjadiqbal/start-seo-kit?style=flat-square)](https://www.npmjs.com/package/@amjadiqbal/start-seo-kit)
13
13
  [![bundle size](https://img.shields.io/bundlephobia/minzip/@amjadiqbal/start-seo-kit?style=flat-square)](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 renders it as `<script type="application/ld+json">` (HTML-escaped).
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, { merchantListing: true })` returns `{ errors, warnings }` based on Google's product snippet, merchant listing, shipping and return-policy documentation (checked 2026-10-22): a missing name, image or offer, a price with a currency symbol, a lowercase currency, shipping in another currency, a finite return window without days, a rating without a count, and so on. It never throws, so it's safe in a unit test over your catalogue:
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 very large tables, give a source `count()` and `entries({ offset, limit })` so each request loads only one chunk: `posts: { count: () => db.posts.count(), entries: ({ offset, limit }) => db.posts.page(offset, limit) }`.
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
- - TanStack Start / Router **1.131 or later** (the `server.handlers` route option). Tested with `@tanstack/react-start` 1.169.3 and `@tanstack/react-router` 1.170.45.
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 the index, route-tree walking, robots rules, the commerce schema and `checkProduct` rules, and a real PNG rendered through satori and resvg-wasm.
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`, a child sitemap, `/robots.txt` and an OG image route, checked in a browser.
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
 
@@ -1,5 +1,5 @@
1
- import { C as jsonLd, a as MetaTag, c as SeoInput, v as JsonLdObject } from "./head-D2pPoGNP.js";
2
- import { o as SitemapEntry } from "./sitemap-DbEixSCi.js";
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-DbEixSCi.js";
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-D2pPoGNP.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-DbEixSCi.js";
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 count = async (src) => Array.isArray(src) ? src.length : typeof src === "function" ? (await src()).length : await src.count();
295
- const slice = async (src, offset, limit) => Array.isArray(src) ? src.slice(offset, offset + limit) : typeof src === "function" ? (await src()).slice(offset, offset + limit) : await src.entries({
296
- offset,
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 counts = await Promise.all(Object.entries(options.sources).map(async ([n, s]) => [
308
- n,
309
- await count(s),
310
- s
311
- ]));
312
- const total = counts.reduce((sum, [, c]) => sum + c, 0);
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
- return new Response(sitemapXml(await all(), options), { headers });
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 [name, c, src] of counts) {
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-DbEixSCi.js";
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), or
49
- * - a paginated source with `count()` and `entries({ offset, limit })` for very large sets,
50
- * so the index never loads every row.
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.0",
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",