@ultimat3/seo 29.3.2 → 30.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
@@ -164,6 +164,7 @@ Tier 1. May import `@ultimat3/core`, `@ultimat3/schema`, `@ultimat3/i18n`. Nothi
164
164
  | `feed-dates.ts` | all timestamp parsing and formatting for feeds. Never `Date.parse` in another module |
165
165
  | `images.ts` | what the markup promises: `srcset` widths, `<picture>` order, inlined dimensions — plus `IMAGE_QUERY_KEYS` and `parseImageQuery`, the contract that reads a minted URL back. Decodes nothing |
166
166
  | `image-driver.ts` | the bytes behind that promise: `ImageTransformDriver` + `builtinImageDriver({ read })` over core's pipeline — encodes png/jpeg/webp |
167
+ | `static-export.ts` | the static build's seam (`globalThis`-slotted, like render's asset table): variant URLs as export files, `og.capture` as a 1200 x 630 `og:image`. Absent in a served process — never read a build fact here |
167
168
 
168
169
  ## Commands
169
170
 
package/README.md CHANGED
@@ -3,6 +3,16 @@
3
3
  SEO is **enforced, not documented**: every rule below is an `assert*` that throws a coded
4
4
  `SeoError` with the route file and the exact edit, never a lint warning.
5
5
 
6
+ `bun add @ultimat3/seo` · guide: [SEO](https://github.com/developerz-ai/ultimate/wiki/SEO)
7
+
8
+ ```ts
9
+ import { buildSitemap, type RouteRecord } from '@ultimat3/seo';
10
+
11
+ declare const routes: readonly RouteRecord[];
12
+
13
+ await buildSitemap(routes, { baseUrl: 'https://example.com', locales: ['en'], defaultLocale: 'en' });
14
+ ```
15
+
6
16
  `x verify`'s **`seo`** step (`packages/cli/src/verify-checks.ts`) runs `validateMeta` over every
7
17
  `site/` route, so missing, duplicate and over-long meta fail the gate `As of 2026-09-23`. Canonical
8
18
  checks are skipped there — an app declares no base URL — and JSON-LD, sitemaps and robots are
@@ -46,6 +56,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
46
56
  | File | Owns |
47
57
  |---|---|
48
58
  | `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` |
59
+ | `static-export.ts` | `setStaticExport()` — the seam a static build installs while it renders: `responsiveImage()` names the export's `/_img/` files instead of `?w=&f=`, and `og: { capture: '/og/<page>' }` (exclusive with `og.image`, by type) becomes `og:image` at 1200 x 630. Unset in a served process, which therefore emits no `og:image` for a capture. `As of 2026-11` |
49
60
  | `validate.ts` | the build gate — `validateMeta()` (`--json`-shaped) and `assertMeta()` |
50
61
  | `ld.ts` | typed JSON-LD builders; required fields are required in the **input type** |
51
62
  | `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 |
@@ -86,7 +97,7 @@ import { buildSitemap, type RouteRecord } from '@ultimat3/seo';
86
97
  declare const routes: readonly RouteRecord[];
87
98
 
88
99
  await buildSitemap(routes, {
89
- baseUrl: 'https://notificado.co',
100
+ baseUrl: 'https://example.com',
90
101
  locales: ['es-co', 'en'],
91
102
  defaultLocale: 'es-co',
92
103
  maxUrls: 1000, // page size; default 50,000
package/package.json CHANGED
@@ -1,7 +1,17 @@
1
1
  {
2
2
  "name": "@ultimat3/seo",
3
- "version": "29.3.2",
3
+ "version": "30.0.0",
4
4
  "description": "Enforced SEO: typed meta, JSON-LD, sitemap, robots, feeds, responsive images, perf budgets",
5
+ "keywords": [
6
+ "ultimate",
7
+ "bun",
8
+ "framework",
9
+ "seo",
10
+ "sitemap",
11
+ "json-ld",
12
+ "meta"
13
+ ],
14
+ "homepage": "https://github.com/developerz-ai/ultimate/wiki/SEO",
5
15
  "license": "MIT",
6
16
  "type": "module",
7
17
  "repository": {
@@ -32,7 +42,7 @@
32
42
  "test": "bun test"
33
43
  },
34
44
  "dependencies": {
35
- "@ultimat3/core": "29.3.2",
36
- "@ultimat3/schema": "29.3.2"
45
+ "@ultimat3/core": "30.0.0",
46
+ "@ultimat3/schema": "30.0.0"
37
47
  }
38
48
  }
package/src/errors.ts CHANGED
@@ -20,6 +20,8 @@ export const SEO_ERROR_CODES = {
20
20
  imageQueryInvalid: 'X_IMAGE_QUERY_INVALID',
21
21
  linkInvalid: 'X_SEO_LINK_INVALID',
22
22
  sitemapEntryInvalid: 'X_SITEMAP_ENTRY_INVALID',
23
+ llmsDescriptionMissing: 'X_SEO_LLMS_DESCRIPTION_MISSING',
24
+ ogImageConflict: 'X_SEO_OG_IMAGE_CONFLICT',
23
25
  } as const;
24
26
 
25
27
  export type SeoErrorCode = (typeof SEO_ERROR_CODES)[keyof typeof SEO_ERROR_CODES];
@@ -46,6 +48,8 @@ registerErrorCodes({
46
48
  X_SITEMAP_ENTRY_INVALID: {
47
49
  title: 'a sitemap entry carries a lastmod or a locale the sitemap cannot write',
48
50
  },
51
+ X_SEO_LLMS_DESCRIPTION_MISSING: { title: 'a page listed in llms.txt has no description' },
52
+ X_SEO_OG_IMAGE_CONFLICT: { title: 'a route meta og declares both image and capture' },
49
53
  });
50
54
 
51
55
  export interface SeoErrorInit {
@@ -134,6 +138,21 @@ export function linkInvalid(link: string, problem: string, file?: string): SeoEr
134
138
  });
135
139
  }
136
140
 
141
+ /**
142
+ * `meta.og` naming both a hosted `image` and a `capture`: the type forbids it, a cast or a JS
143
+ * caller does not, and the two renders would disagree (the export publishes the capture, a served
144
+ * page names no `og:image`). `file` as for `linkInvalid`.
145
+ */
146
+ export function ogImageConflict(file?: string): SeoError {
147
+ const where = file === undefined ? 'the route' : file;
148
+ return new SeoError({
149
+ code: SEO_ERROR_CODES.ogImageConflict,
150
+ cause: `${where} declares meta.og with both image and capture`,
151
+ fix: `og: { image: '/card.png' } # or og: { capture: '/og/<page>' }, never both, in defineRoute({ meta }) in ${where}`,
152
+ meta: file === undefined ? {} : { file },
153
+ });
154
+ }
155
+
137
156
  export function ldInvalid(type: string, field: string, hint: string): SeoError {
138
157
  return new SeoError({
139
158
  code: SEO_ERROR_CODES.ldInvalid,
package/src/images.ts CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  import { canEncode, finiteCount } from '@ultimat3/core';
7
7
  import { imageQueryInvalid } from './errors';
8
+ import { currentStaticExport, isExportableSrc } from './static-export';
8
9
  import { attributes, escapeXml } from './xml';
9
10
 
10
11
  /** Ordered widest-first is wrong for `srcset`; browsers want ascending. */
@@ -98,6 +99,18 @@ function defaultUrlFor(src: string, width: number, format?: string): string {
98
99
  return `${src}${separator}${widthParam}${formatParam}`;
99
100
  }
100
101
 
102
+ /**
103
+ * The variant URL while a static export renders: a FILE path the export writes, because a dumb
104
+ * file server ignores `?w=&f=` and hands back the full original under a `<source type="image/webp">`.
105
+ * `undefined` in a served process (no export installed) and for a remote `src`, which keep the
106
+ * query URLs the media route — or a CDN — answers. A caller's own `urlFor` always wins.
107
+ */
108
+ function exportUrlFor(src: string): ResponsiveImageOptions['urlFor'] {
109
+ const exported = currentStaticExport();
110
+ if (exported === undefined || !isExportableSrc(src)) return undefined;
111
+ return (from, width, format) => exported.imageUrl({ src: from, width, format });
112
+ }
113
+
101
114
  export interface ImageQuery {
102
115
  readonly width?: number | undefined;
103
116
  readonly format?: string | undefined;
@@ -220,7 +233,7 @@ export function responsiveImage(
220
233
  input: ImageInput,
221
234
  options: ResponsiveImageOptions = {},
222
235
  ): ResponsiveImage {
223
- const urlFor = options.urlFor ?? defaultUrlFor;
236
+ const urlFor = options.urlFor ?? exportUrlFor(input.src) ?? defaultUrlFor;
224
237
  const widths = usableWidths(input.width, options.widths ?? DEFAULT_WIDTHS);
225
238
  const sizes = input.sizes ?? '100vw';
226
239
  const formats = options.formats ?? DEFAULT_FORMATS;
package/src/index.ts CHANGED
@@ -76,6 +76,8 @@ export {
76
76
  } from './ld';
77
77
  export type { HeadLink, HeadLinkAs, HeadLinkRel } from './links';
78
78
  export { linkProblem } from './links';
79
+ export type { LlmsSite } from './llms';
80
+ export { buildLlmsTxt, LLMS_PATH, llmsDescriptionIssues } from './llms';
79
81
  export type { LocalizedPage, MetaLocalization } from './locale-tags';
80
82
  export { hreflangTag, ogLocaleTag } from './locale-tags';
81
83
  export type {
@@ -123,6 +125,8 @@ export {
123
125
  export { sitemapDatetime } from './sitemap-datetime';
124
126
  export type { SitemapSection } from './sitemap-sections';
125
127
  export { sectionOf } from './sitemap-sections';
128
+ export type { StaticExport, StaticImageRequest } from './static-export';
129
+ export { currentStaticExport, isExportableSrc, setStaticExport } from './static-export';
126
130
  export type { MetaIssue, MetaValidationReport, ValidateMetaOptions } from './validate';
127
131
  export { assertMeta, validateMeta } from './validate';
128
132
  export { absoluteUrl, escapeXml } from './xml';
package/src/llms.ts ADDED
@@ -0,0 +1,74 @@
1
+ // Single responsibility: `/llms.txt` — the site's public pages, by title and description, for a
2
+ // language model — built from the same route records the sitemap reads. One answer, served by a
3
+ // running web role and written by the static export alike, and empty outside production for
4
+ // `robots.txt`'s reason: a branch deploy must not be what a model learns the site from.
5
+
6
+ import type { Environment } from '@ultimat3/core';
7
+ import { DEFAULT_ENVIRONMENT, tryResolveEnvironment } from '@ultimat3/core';
8
+ import { SEO_ERROR_CODES } from './errors';
9
+ import { isIndexable } from './robots';
10
+ import type { RouteRecord } from './routes';
11
+ import { isDynamic } from './routes';
12
+ import type { MetaIssue } from './validate';
13
+ import { absoluteUrl } from './xml';
14
+
15
+ export const LLMS_PATH = '/llms.txt';
16
+
17
+ export interface LlmsSite {
18
+ readonly baseUrl: string;
19
+ /** Omitted: `ULTIMATE_ENV`. Anything but `production` answers an empty document. */
20
+ readonly environment?: Environment | undefined;
21
+ /** `seo.llms` — `false` answers an empty document everywhere. */
22
+ readonly enabled?: boolean | undefined;
23
+ }
24
+
25
+ /** A listed page: an indexable `site/` route whose meta resolved without a request. */
26
+ const listed = (route: RouteRecord): boolean =>
27
+ route.surface === 'site' &&
28
+ route.noindex !== true &&
29
+ route.meta?.robots?.index !== false &&
30
+ route.meta?.title !== undefined &&
31
+ !isDynamic(route.path);
32
+
33
+ /** One line, so a newline in a title cannot open a second Markdown entry. */
34
+ const oneLine = (text: string): string => text.replace(/\s+/g, ' ').trim();
35
+ const linkText = (text: string): string => oneLine(text).replace(/[[\]]/g, '\\$&');
36
+
37
+ /**
38
+ * `# <home title>`, `> <home description>`, then `- [title](url): description` per listed page,
39
+ * in path order. The home page names the site: no second site name to keep in step with its title.
40
+ */
41
+ export function buildLlmsTxt(routes: readonly RouteRecord[], site: LlmsSite): string {
42
+ const environment = site.environment ?? tryResolveEnvironment() ?? DEFAULT_ENVIRONMENT;
43
+ if (site.enabled === false || !isIndexable(environment)) return '';
44
+ const pages = routes
45
+ .filter(listed)
46
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
47
+ const home = pages.find((route) => route.path === '/');
48
+ const lines = [`# ${oneLine(home?.meta?.title ?? site.baseUrl)}`, ''];
49
+ const summary = home?.meta?.description;
50
+ if (summary !== undefined) lines.push(`> ${oneLine(summary)}`, '');
51
+ for (const route of pages) {
52
+ const description = route.meta?.description;
53
+ const link = `- [${linkText(route.meta?.title ?? route.path)}](${absoluteUrl(site.baseUrl, route.path)})`;
54
+ lines.push(description === undefined ? link : `${link}: ${oneLine(description)}`);
55
+ }
56
+ return `${lines.join('\n')}\n`;
57
+ }
58
+
59
+ /**
60
+ * The convention's build error: every page `/llms.txt` lists says what it is. The `seo` step asks
61
+ * only while `seo.llms` is on; a page with no description is a line that tells a model nothing.
62
+ * `validateMeta`'s issue shape, so the step reports both through one projection.
63
+ */
64
+ export function llmsDescriptionIssues(routes: readonly RouteRecord[]): readonly MetaIssue[] {
65
+ return routes
66
+ .filter((route) => listed(route) && route.meta?.description === undefined)
67
+ .map((route) => ({
68
+ code: SEO_ERROR_CODES.llmsDescriptionMissing,
69
+ route: route.path,
70
+ file: route.file,
71
+ cause: `${route.file} (route "${route.path}") is listed in /llms.txt and has no meta.description, so its line says nothing about the page`,
72
+ fix: `x verify --only seo --json # then add description to defineRoute({ meta }) in ${route.file}, or set seo: { llms: false } in app.config.ts`,
73
+ }));
74
+ }
package/src/meta.ts CHANGED
@@ -2,8 +2,10 @@
2
2
  // for a global default: a route that does not declare a description does not get
3
3
  // one, it fails the build (see validate.ts).
4
4
 
5
+ import { ogImageConflict } from './errors';
5
6
  import { type HeadLink, linkTags } from './links';
6
7
  import { hreflangTag, type MetaLocalization, ogLocaleTag } from './locale-tags';
8
+ import { currentStaticExport } from './static-export';
7
9
  import { absoluteUrl } from './xml';
8
10
 
9
11
  /**
@@ -32,14 +34,13 @@ export interface OpenGraphImage {
32
34
  type?: string;
33
35
  }
34
36
 
35
- export interface OpenGraph {
37
+ interface OpenGraphFields {
36
38
  type?: 'website' | 'article' | 'profile' | 'product' | 'video.other';
37
39
  title?: string;
38
40
  description?: string;
39
41
  url?: string;
40
42
  siteName?: string;
41
43
  locale?: string;
42
- image?: string | OpenGraphImage;
43
44
  publishedTime?: string;
44
45
  modifiedTime?: string;
45
46
  author?: string;
@@ -47,6 +48,20 @@ export interface OpenGraph {
47
48
  tags?: readonly string[];
48
49
  }
49
50
 
51
+ /**
52
+ * The card's picture: a URL you host (`image`), or a `site/` page the static build photographs at
53
+ * 1200 x 630 (`capture: '/og/post/hello'`, a concrete path) — one or the other, never both, which
54
+ * the type refuses. A captured card is written to `/_og/<path>.png` and stamped as `og:image` with
55
+ * its size by `x build --target static`; a served document carries no `og:image` for it, because
56
+ * the container holds neither the export's PNG nor a browser to take one, and an `og:image` naming
57
+ * a file nothing serves is a broken preview a crawler caches. Served, the card is `image`'s.
58
+ */
59
+ export type OpenGraph = OpenGraphFields &
60
+ (
61
+ | { image?: string | OpenGraphImage; capture?: undefined }
62
+ | { capture: string; image?: undefined }
63
+ );
64
+
50
65
  export interface TwitterCard {
51
66
  card?: 'summary' | 'summary_large_image' | 'app' | 'player';
52
67
  site?: string;
@@ -269,8 +284,12 @@ function socialTags(
269
284
  });
270
285
  }
271
286
  }
272
- if (og.image !== undefined) {
273
- const image = typeof og.image === 'string' ? { url: og.image } : og.image;
287
+ // A capture is the export's to publish (`static-export.ts`); served, it has no file to name.
288
+ if (og.image !== undefined && og.capture !== undefined) throw ogImageConflict();
289
+ const declared =
290
+ og.capture === undefined ? og.image : currentStaticExport()?.cardImage(og.capture);
291
+ if (declared !== undefined) {
292
+ const image = typeof declared === 'string' ? { url: declared } : declared;
274
293
  tags.push({ tag: 'meta', attrs: { property: 'og:image', content: abs(image.url) } });
275
294
  if (image.width !== undefined) {
276
295
  tags.push({
@@ -306,7 +325,7 @@ function socialTags(
306
325
  // --- Twitter ---------------------------------------------------------------
307
326
  const twitter = meta.twitter ?? {};
308
327
  const twitterEntries: Array<[string, string | undefined]> = [
309
- ['twitter:card', twitter.card ?? (og.image === undefined ? 'summary' : 'summary_large_image')],
328
+ ['twitter:card', twitter.card ?? (declared === undefined ? 'summary' : 'summary_large_image')],
310
329
  ['twitter:site', twitter.site],
311
330
  ['twitter:creator', twitter.creator],
312
331
  ['twitter:title', og.title ?? meta.title],
@@ -0,0 +1,49 @@
1
+ // The seam a static export installs while it renders: where an image variant and a captured social
2
+ // card live as FILES. Absent at serve time, so a served document never pays for it — and a dumb
3
+ // file server ignores `?w=&f=`, which is why the export cannot use the query URLs the media route
4
+ // answers. The exporter owns the files; this package only asks it for their URLs.
5
+
6
+ import type { OpenGraphImage } from './meta';
7
+
8
+ /** One variant a document asked for: `format` undefined is the source's own encoding. */
9
+ export interface StaticImageRequest {
10
+ readonly src: string;
11
+ readonly width: number;
12
+ readonly format: string | undefined;
13
+ }
14
+
15
+ export interface StaticExport {
16
+ /** The site path one variant is written at, e.g. `/_img/3f2a1b9c-640.webp`. Records it. */
17
+ imageUrl(request: StaticImageRequest): string;
18
+ /** The `og:image` a declared `og.capture` is published as. Records the capture. */
19
+ cardImage(capturePath: string): OpenGraphImage;
20
+ }
21
+
22
+ /**
23
+ * Registered globally, like render's asset table, so two copies of this module — the app's and the
24
+ * CLI's, resolved through different `node_modules` — read the seam the one export installed.
25
+ */
26
+ const SLOT: unique symbol = Symbol.for('ultimate.seo.staticExport') as never;
27
+
28
+ interface Holder {
29
+ [SLOT]?: StaticExport | undefined;
30
+ }
31
+
32
+ const holder = globalThis as unknown as Holder;
33
+
34
+ /** Installed by the static export around its renders; `undefined` uninstalls. */
35
+ export function setStaticExport(exported: StaticExport | undefined): void {
36
+ holder[SLOT] = exported;
37
+ }
38
+
39
+ /** The installed export, or `undefined` — always `undefined` in a served process. */
40
+ export function currentStaticExport(): StaticExport | undefined {
41
+ return holder[SLOT];
42
+ }
43
+
44
+ /**
45
+ * A `src` the export reads from its own tree: a site path. A remote URL (`https:`, `//`) or an
46
+ * inline `data:` image is somebody else's bytes, and keeps the query URL a CDN may answer.
47
+ */
48
+ export const isExportableSrc = (src: string): boolean =>
49
+ src.startsWith('/') && !src.startsWith('//');
package/src/validate.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  duplicateMeta,
11
11
  metaMissing,
12
12
  metaTooLong,
13
+ ogImageConflict,
13
14
  SeoError,
14
15
  type SeoErrorCode,
15
16
  titleTemplateSlotMissing,
@@ -138,6 +139,11 @@ export function validateMeta(
138
139
  }
139
140
  }
140
141
 
142
+ // The renderer's refusal, at the route FILE and before any request.
143
+ if (meta.og?.image !== undefined && meta.og.capture !== undefined) {
144
+ issues.push(issueOf(ogImageConflict(route.file), route.path, route.file));
145
+ }
146
+
141
147
  // A canonical is only checkable for a static path: a dynamic route's
142
148
  // canonical is produced per-item at render time.
143
149
  if (meta.canonical !== undefined && options.baseUrl !== undefined && !isDynamic(route.path)) {