@ultimat3/seo 22.15.0 → 23.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
@@ -114,6 +114,11 @@ Tier 1. May import `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/i18n`. Nothi
114
114
  `Math.max(...times)`: a spread is one argument per item and the engine's limit is the caller's
115
115
  stack depth, not the feed's size. "Now" arrives through `BuildFeedOptions.clock`; nothing in
116
116
  `rss.ts` reads a clock of its own.
117
+ - **A document that is not indexed carries no social card.** `renderMeta` emits `og:*`, `article:*`
118
+ and `twitter:*` only when `meta.robots?.index !== false` — declared or derived, one rule and no
119
+ `social: false` beside it. The tags were unconditional, so every admin screen, every 4xx/5xx
120
+ (`defineRoute` marks them `noindex`) and every draft published its title and description to
121
+ whoever pasted the URL into a chat. `follow: false` alone withdraws nothing. `meta.test.ts`.
117
122
  - Only `site/` routes are SEO-checked — `app/` is behind auth and crawlers never authenticate.
118
123
 
119
124
  ## Files
package/README.md CHANGED
@@ -44,7 +44,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
44
44
 
45
45
  | File | Owns |
46
46
  |---|---|
47
- | `meta.ts` | the metadata model, `renderMeta()` → head tags: title template, canonical, robots, `og:*`, `twitter:*`, hreflang + `x-default`, `theme-color` per colour scheme |
47
+ | `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
48
  | `validate.ts` | the build gate — `validateMeta()` (`--json`-shaped) and `assertMeta()` |
49
49
  | `ld.ts` | typed JSON-LD builders; required fields are required in the **input type** |
50
50
  | `sitemap.ts` | `buildSitemap()` from the route table + each route's `prerender()`, per-locale alternates, automatic index splitting past 50k |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/seo",
3
- "version": "22.15.0",
3
+ "version": "23.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",
@@ -19,6 +19,7 @@
19
19
  "files": [
20
20
  "src",
21
21
  "!src/**/*.test.ts",
22
+ "!src/**/*-fixture.ts",
22
23
  "CLAUDE.md",
23
24
  "README.md",
24
25
  "LICENSE"
@@ -31,7 +32,7 @@
31
32
  "test": "bun test"
32
33
  },
33
34
  "dependencies": {
34
- "@ultimat3/core": "22.15.0",
35
- "@ultimat3/schema": "22.15.0"
35
+ "@ultimat3/core": "23.0.0",
36
+ "@ultimat3/schema": "23.0.0"
36
37
  }
37
38
  }
package/src/meta.ts CHANGED
@@ -197,35 +197,23 @@ export function hreflangSet(
197
197
  return href === undefined ? alternates : [...alternates, { hreflang: 'x-default', href }];
198
198
  }
199
199
 
200
- export function renderMeta(meta: RouteMeta, options: RenderMetaOptions = {}): readonly HeadTag[] {
200
+ /**
201
+ * The card a link preview is built from: `og:*`, `article:*`, `twitter:*`.
202
+ *
203
+ * Emitted only for a document that may be INDEXED. `robots: { index: false }` withdraws the whole
204
+ * set, declared or derived — one rule, no second switch: a page its author keeps out of a search
205
+ * result is a page behind sign-in, an error, or a draft, and a card for it publishes the title and
206
+ * description the `noindex` was hiding. Every admin document carried one until 2026-10.
207
+ */
208
+ function socialTags(
209
+ meta: RouteMeta,
210
+ localization: MetaLocalization | undefined,
211
+ canonicalHref: string | undefined,
212
+ abs: (value: string) => string,
213
+ ): readonly HeadTag[] {
214
+ if (meta.robots?.index === false) return [];
201
215
  const tags: HeadTag[] = [];
202
- const abs = (value: string): string =>
203
- options.baseUrl === undefined ? value : absoluteUrl(options.baseUrl, value);
204
-
205
- const canonical = meta.canonical ?? (options.path === undefined ? undefined : options.path);
206
- const canonicalHref = canonical === undefined ? undefined : abs(canonical);
207
-
208
- if (meta.title !== undefined) {
209
- tags.push({
210
- tag: 'title',
211
- attrs: {},
212
- text: applyTitleTemplate(meta.title, meta.titleTemplate),
213
- });
214
- }
215
- if (meta.description !== undefined) {
216
- tags.push({ tag: 'meta', attrs: { name: 'description', content: meta.description } });
217
- }
218
- if (canonicalHref !== undefined) {
219
- tags.push({ tag: 'link', attrs: { rel: 'canonical', href: canonicalHref } });
220
- }
221
- tags.push({
222
- tag: 'meta',
223
- attrs: { name: 'robots', content: robotsContent(meta.robots ?? {}) },
224
- });
225
-
226
- // --- Open Graph ------------------------------------------------------------
227
216
  const og = meta.og ?? {};
228
- const localization = options.localization;
229
217
  const ogLocale =
230
218
  og.locale ?? (localization === undefined ? undefined : ogLocaleTag(localization.locale));
231
219
  const ogEntries: Array<[string, string | undefined]> = [
@@ -298,6 +286,37 @@ export function renderMeta(meta: RouteMeta, options: RenderMetaOptions = {}): re
298
286
  for (const [name, content] of twitterEntries) {
299
287
  if (content !== undefined) tags.push({ tag: 'meta', attrs: { name, content } });
300
288
  }
289
+ return tags;
290
+ }
291
+
292
+ export function renderMeta(meta: RouteMeta, options: RenderMetaOptions = {}): readonly HeadTag[] {
293
+ const tags: HeadTag[] = [];
294
+ const abs = (value: string): string =>
295
+ options.baseUrl === undefined ? value : absoluteUrl(options.baseUrl, value);
296
+
297
+ const canonical = meta.canonical ?? (options.path === undefined ? undefined : options.path);
298
+ const canonicalHref = canonical === undefined ? undefined : abs(canonical);
299
+
300
+ if (meta.title !== undefined) {
301
+ tags.push({
302
+ tag: 'title',
303
+ attrs: {},
304
+ text: applyTitleTemplate(meta.title, meta.titleTemplate),
305
+ });
306
+ }
307
+ if (meta.description !== undefined) {
308
+ tags.push({ tag: 'meta', attrs: { name: 'description', content: meta.description } });
309
+ }
310
+ if (canonicalHref !== undefined) {
311
+ tags.push({ tag: 'link', attrs: { rel: 'canonical', href: canonicalHref } });
312
+ }
313
+ tags.push({
314
+ tag: 'meta',
315
+ attrs: { name: 'robots', content: robotsContent(meta.robots ?? {}) },
316
+ });
317
+
318
+ const localization = options.localization;
319
+ tags.push(...socialTags(meta, localization, canonicalHref, abs));
301
320
 
302
321
  // --- hreflang --------------------------------------------------------------
303
322
  // A route's own `alternates` win whole; otherwise the cluster comes from the routed locales.