@ultimat3/seo 27.16.1 → 29.0.0

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/CLAUDE.md CHANGED
@@ -141,11 +141,15 @@ Tier 1. May import `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/i18n`. Nothi
141
141
  `Math.max(...times)`: a spread is one argument per item and the engine's limit is the caller's
142
142
  stack depth, not the feed's size. "Now" arrives through `BuildFeedOptions.clock`; nothing in
143
143
  `rss.ts` reads a clock of its own.
144
- - **A document that is not indexed carries no social card.** `renderMeta` emits `og:*`, `article:*`
145
- and `twitter:*` only when `meta.robots?.index !== false` — declared or derived, one rule and no
146
- `social: false` beside it. The tags were unconditional, so every admin screen, every 4xx/5xx
147
- (`defineRoute` marks them `noindex`) and every draft published its title and description to
148
- whoever pasted the URL into a chat. `follow: false` alone withdraws nothing. `meta.test.ts`.
144
+ - **A document that is not indexed carries no social card — unless the route asks for one.**
145
+ `renderMeta` emits `og:*`, `article:*` and `twitter:*` unless `meta.robots?.index === false`, and
146
+ a route declaring `socialCard: true` gets the card anyway: exactly the card an indexable
147
+ document gets, declared values winning and the derived ones coming with it. That field is the
148
+ ONE switch — the robots line is never touched by it, so the route stays noindex, and a route
149
+ without it keeps today's suppression. The tags were unconditional, so every admin screen, every
150
+ 4xx/5xx (`defineRoute` marks them `noindex`) and every draft published its title and description
151
+ to whoever pasted the URL into a chat. `follow: false` alone withdraws nothing.
152
+ `meta-social.test.ts`.
149
153
  - Only `site/` routes are SEO-checked — `app/` is behind auth and crawlers never authenticate.
150
154
 
151
155
  ## Files
package/README.md CHANGED
@@ -45,7 +45,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
45
45
 
46
46
  | File | Owns |
47
47
  |---|---|
48
- | `meta.ts` | the metadata model, `renderMeta()` → head tags: title template, canonical, robots, `og:*`, `twitter:*`, hreflang + `x-default`, `theme-color` per colour scheme. **`robots: { index: false }` withdraws every `og:*`, `article:*` and `twitter:*` tag** — declared or derived; there is no `social` switch |
48
+ | `meta.ts` | the metadata model, `renderMeta()` → head tags: title template, canonical, robots, `og:*`, `twitter:*`, hreflang + `x-default`, `theme-color` per colour scheme. **`robots: { index: false }` withdraws every `og:*`, `article:*` and `twitter:*` tag** — declared or derived; the one opt-in is `socialCard: true` |
49
49
  | `validate.ts` | the build gate — `validateMeta()` (`--json`-shaped) and `assertMeta()` |
50
50
  | `ld.ts` | typed JSON-LD builders; required fields are required in the **input type** |
51
51
  | `sitemap.ts` | `buildSitemap()` from the route table + each route's `prerender()`, per-locale alternates. One urlset at `/sitemap.xml`; past a cap (`SITEMAP_MAX_URLS` 50,000, `SITEMAP_MAX_BYTES` 50 MB) the index stays `/sitemap.xml` and the parts are `/sitemaps/<n>.xml` (`SITEMAP_PARTS_DIR`). With `sections` it is always an index of `/sitemaps/<name>-<n>.xml` — see below |
@@ -68,6 +68,16 @@ ld.Article({ headline: 'Ship it', author: { name: 'Ada' } });
68
68
  A missing `datePublished` is a compile error, not a Search Console warning three
69
69
  weeks later.
70
70
 
71
+ ## Social card
72
+
73
+ A document that may not be indexed carries no `og:*`, `article:*` or `twitter:*` tag — declared or
74
+ derived. One opt-in, declared by the route:
75
+
76
+ | Rule | |
77
+ |---|---|
78
+ | `socialCard: true` | the card is emitted exactly as for an indexable page — declared values win, the derived ones come with it |
79
+ | the robots directives | never changed by the opt-in — the route stays noindex |
80
+
71
81
  ## Sitemap: pages, sections, caps
72
82
 
73
83
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/seo",
3
- "version": "27.16.1",
3
+ "version": "29.0.0",
4
4
  "description": "Enforced SEO: typed meta, JSON-LD, sitemap, robots, feeds, responsive images, perf budgets",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,14 +25,14 @@
25
25
  "LICENSE"
26
26
  ],
27
27
  "engines": {
28
- "bun": ">=1.4.2"
28
+ "bun": ">=1.4.4"
29
29
  },
30
30
  "scripts": {
31
31
  "typecheck": "tsc --noEmit -p tsconfig.json",
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/core": "27.16.1",
36
- "@ultimat3/schema": "27.16.1"
35
+ "@ultimat3/core": "29.0.0",
36
+ "@ultimat3/schema": "29.0.0"
37
37
  }
38
38
  }
package/src/meta.ts CHANGED
@@ -74,6 +74,13 @@ export interface RouteMeta {
74
74
  description?: string;
75
75
  canonical?: string;
76
76
  robots?: RobotsDirectives;
77
+ /**
78
+ * The social card opt-in for a document that may NOT be indexed. `robots: { index: false }`
79
+ * withdraws every `og:*`, `article:*` and `twitter:*` tag; a route declaring `socialCard: true`
80
+ * gets the card anyway — emitted exactly as an indexable document's, declared values winning and
81
+ * the derived ones coming with it. The robots directives are never touched by this field.
82
+ */
83
+ socialCard?: boolean;
77
84
  og?: OpenGraph;
78
85
  twitter?: TwitterCard;
79
86
  /** One entry per locale. `x-default` is added automatically when absent. */
@@ -222,10 +229,12 @@ export function hreflangSet(
222
229
  /**
223
230
  * The card a link preview is built from: `og:*`, `article:*`, `twitter:*`.
224
231
  *
225
- * Emitted only for a document that may be INDEXED. `robots: { index: false }` withdraws the whole
226
- * set, declared or derived — one rule, no second switch: a page its author keeps out of a search
227
- * result is a page behind sign-in, an error, or a draft, and a card for it publishes the title and
228
- * description the `noindex` was hiding. Every admin document carried one until 2026-10.
232
+ * Withdrawn from a document that may not be INDEXED: `robots: { index: false }` is a page its
233
+ * author keeps out of a search result — behind sign-in, an error, or a draft — and a card for it
234
+ * publishes the title and description the `noindex` was hiding. A route that wants the card anyway
235
+ * says so ONCE, with `socialCard: true`, and then gets exactly the card an indexable document
236
+ * gets: declared values win, the derived ones come with it. That field never touches the robots
237
+ * directives, so the route stays noindex.
229
238
  */
230
239
  function socialTags(
231
240
  meta: RouteMeta,
@@ -233,7 +242,7 @@ function socialTags(
233
242
  canonicalHref: string | undefined,
234
243
  abs: (value: string) => string,
235
244
  ): readonly HeadTag[] {
236
- if (meta.robots?.index === false) return [];
245
+ if (meta.robots?.index === false && meta.socialCard !== true) return [];
237
246
  const tags: HeadTag[] = [];
238
247
  const og = meta.og ?? {};
239
248
  const ogLocale =