@ultimat3/seo 29.4.0 → 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 +1 -0
- package/README.md +12 -1
- package/package.json +13 -3
- package/src/errors.ts +19 -0
- package/src/images.ts +14 -1
- package/src/index.ts +4 -0
- package/src/llms.ts +74 -0
- package/src/meta.ts +24 -5
- package/src/static-export.ts +49 -0
- package/src/validate.ts +6 -0
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://
|
|
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": "
|
|
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": "
|
|
36
|
-
"@ultimat3/schema": "
|
|
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
|
-
|
|
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
|
-
|
|
273
|
-
|
|
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 ?? (
|
|
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)) {
|