@ultimat3/seo 22.6.1 → 22.7.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/package.json +3 -3
- package/src/errors.ts +17 -0
- package/src/index.ts +3 -0
- package/src/links.ts +114 -0
- package/src/meta.ts +10 -0
- package/src/validate.ts +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/seo",
|
|
3
|
-
"version": "22.
|
|
3
|
+
"version": "22.7.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",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"test": "bun test"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@ultimat3/core": "22.
|
|
35
|
-
"@ultimat3/schema": "22.
|
|
34
|
+
"@ultimat3/core": "22.7.0",
|
|
35
|
+
"@ultimat3/schema": "22.7.0"
|
|
36
36
|
}
|
|
37
37
|
}
|
package/src/errors.ts
CHANGED
|
@@ -18,6 +18,7 @@ export const SEO_ERROR_CODES = {
|
|
|
18
18
|
ldInvalid: 'X_LD_INVALID',
|
|
19
19
|
sitemapTooLarge: 'X_SITEMAP_TOO_LARGE',
|
|
20
20
|
imageQueryInvalid: 'X_IMAGE_QUERY_INVALID',
|
|
21
|
+
linkInvalid: 'X_SEO_LINK_INVALID',
|
|
21
22
|
} as const;
|
|
22
23
|
|
|
23
24
|
export type SeoErrorCode = (typeof SEO_ERROR_CODES)[keyof typeof SEO_ERROR_CODES];
|
|
@@ -38,6 +39,7 @@ registerErrorCodes({
|
|
|
38
39
|
X_LD_INVALID: { title: 'JSON-LD node is missing a required schema.org field' },
|
|
39
40
|
X_SITEMAP_TOO_LARGE: { title: 'sitemap exceeds the 50,000-entry protocol limit' },
|
|
40
41
|
X_IMAGE_QUERY_INVALID: { title: 'an image transform query parameter is present but unusable' },
|
|
42
|
+
X_SEO_LINK_INVALID: { title: 'a route meta link is unsafe or would fetch nothing' },
|
|
41
43
|
});
|
|
42
44
|
|
|
43
45
|
export interface SeoErrorInit {
|
|
@@ -111,6 +113,21 @@ export function canonicalMismatch(file: string, canonical: string, expected: str
|
|
|
111
113
|
});
|
|
112
114
|
}
|
|
113
115
|
|
|
116
|
+
/**
|
|
117
|
+
* A `meta.links` entry that cannot ship: an href that is not a path or an http(s) URL, a preload
|
|
118
|
+
* with no `as`, a font preload with no `crossorigin`. `file` is known to the build gate and not to
|
|
119
|
+
* a render — the renderer's message names the link, which the route's source contains verbatim.
|
|
120
|
+
*/
|
|
121
|
+
export function linkInvalid(link: string, problem: string, file?: string): SeoError {
|
|
122
|
+
const where = file === undefined ? 'the route' : file;
|
|
123
|
+
return new SeoError({
|
|
124
|
+
code: SEO_ERROR_CODES.linkInvalid,
|
|
125
|
+
cause: `${where} declares meta ${link}, which ${problem}`,
|
|
126
|
+
fix: `edit links in defineRoute({ meta }) in ${where}: a path or an http(s) href, \`as\` on every preload, crossorigin: 'anonymous' on a font preload`,
|
|
127
|
+
meta: { link, problem, ...(file === undefined ? {} : { file }) },
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
114
131
|
export function ldInvalid(type: string, field: string, hint: string): SeoError {
|
|
115
132
|
return new SeoError({
|
|
116
133
|
code: SEO_ERROR_CODES.ldInvalid,
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ export {
|
|
|
7
7
|
duplicateMeta,
|
|
8
8
|
imageQueryInvalid,
|
|
9
9
|
ldInvalid,
|
|
10
|
+
linkInvalid,
|
|
10
11
|
metaMissing,
|
|
11
12
|
metaTooLong,
|
|
12
13
|
notImplementedDriver,
|
|
@@ -70,6 +71,8 @@ export {
|
|
|
70
71
|
SoftwareApplication,
|
|
71
72
|
WebSite,
|
|
72
73
|
} from './ld';
|
|
74
|
+
export type { HeadLink, HeadLinkAs, HeadLinkRel } from './links';
|
|
75
|
+
export { linkProblem } from './links';
|
|
73
76
|
export type { LocalizedPage, MetaLocalization } from './locale-tags';
|
|
74
77
|
export { hreflangTag, ogLocaleTag } from './locale-tags';
|
|
75
78
|
export type {
|
package/src/links.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// `RouteMeta.links`: the `<link>` tags a route adds to `<head>` beyond the two this package
|
|
2
|
+
// already derives (canonical, hreflang). A font preload, a preconnect, a feed. Typed so a rel the
|
|
3
|
+
// framework owns elsewhere — `canonical` (`meta.canonical`), `stylesheet` (the surface bundle) —
|
|
4
|
+
// is not spellable here, and checked so an href is always a URL a browser fetches: the value lands
|
|
5
|
+
// in an attribute of the document every visitor loads, and `javascript:` in a `<link href>` is
|
|
6
|
+
// script the page never meant to carry.
|
|
7
|
+
|
|
8
|
+
import { linkInvalid, type SeoError } from './errors';
|
|
9
|
+
import type { HeadTag } from './meta';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Every rel a route may declare. `canonical` and `alternate`+`hreflang` have their own fields;
|
|
13
|
+
* `stylesheet` is the surface bundle's, emitted once per document by the renderer.
|
|
14
|
+
*/
|
|
15
|
+
export type HeadLinkRel =
|
|
16
|
+
| 'preload'
|
|
17
|
+
| 'modulepreload'
|
|
18
|
+
| 'prefetch'
|
|
19
|
+
| 'preconnect'
|
|
20
|
+
| 'dns-prefetch'
|
|
21
|
+
| 'icon'
|
|
22
|
+
| 'apple-touch-icon'
|
|
23
|
+
| 'manifest'
|
|
24
|
+
| 'alternate'
|
|
25
|
+
| 'author'
|
|
26
|
+
| 'license'
|
|
27
|
+
| 'help'
|
|
28
|
+
| 'privacy-policy'
|
|
29
|
+
| 'terms-of-service'
|
|
30
|
+
| 'search'
|
|
31
|
+
| 'me';
|
|
32
|
+
|
|
33
|
+
/** The destinations a preload can name (HTML's `as` keywords). */
|
|
34
|
+
export type HeadLinkAs =
|
|
35
|
+
| 'audio'
|
|
36
|
+
| 'document'
|
|
37
|
+
| 'embed'
|
|
38
|
+
| 'fetch'
|
|
39
|
+
| 'font'
|
|
40
|
+
| 'image'
|
|
41
|
+
| 'object'
|
|
42
|
+
| 'script'
|
|
43
|
+
| 'style'
|
|
44
|
+
| 'track'
|
|
45
|
+
| 'video'
|
|
46
|
+
| 'worker';
|
|
47
|
+
|
|
48
|
+
export interface HeadLink {
|
|
49
|
+
readonly rel: HeadLinkRel;
|
|
50
|
+
/** A path (`/assets/…`, from `asset()`) or an `http(s)` URL. Nothing else is fetched. */
|
|
51
|
+
readonly href: string;
|
|
52
|
+
/** Required on `preload`: a preload without it fetches nothing. */
|
|
53
|
+
readonly as?: HeadLinkAs;
|
|
54
|
+
/** A MIME type: `font/woff2`, `application/rss+xml`. */
|
|
55
|
+
readonly type?: string;
|
|
56
|
+
/** Required on a font preload: fonts are CORS requests, and a mismatched preload is a 2nd fetch. */
|
|
57
|
+
readonly crossorigin?: 'anonymous' | 'use-credentials';
|
|
58
|
+
/** A media query: the link applies only where it matches. */
|
|
59
|
+
readonly media?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The scheme of `href`, or `undefined` for a relative reference. Read the way a browser's URL
|
|
64
|
+
* parser reads it: ASCII tab and newline are removed anywhere and leading C0/space trimmed first,
|
|
65
|
+
* so `java\tscript:` and ` javascript:` are the scheme they execute as.
|
|
66
|
+
*/
|
|
67
|
+
function schemeOf(href: string): string | undefined {
|
|
68
|
+
let start = 0;
|
|
69
|
+
while (start < href.length && href.charCodeAt(start) <= 0x20) start += 1;
|
|
70
|
+
const normalised = href.slice(start).replace(/[\t\n\r]/g, '');
|
|
71
|
+
const match = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(normalised);
|
|
72
|
+
return match?.[1]?.toLowerCase();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Why `link` cannot ship, or `undefined`. One predicate for the renderer and the build gate. */
|
|
76
|
+
export function linkProblem(link: HeadLink): string | undefined {
|
|
77
|
+
if (typeof link.href !== 'string' || link.href.trim() === '') return 'has an empty href';
|
|
78
|
+
const scheme = schemeOf(link.href);
|
|
79
|
+
if (scheme !== undefined && scheme !== 'http' && scheme !== 'https') {
|
|
80
|
+
return `has an href with the "${scheme}:" scheme — only a path or an http(s) URL is fetched`;
|
|
81
|
+
}
|
|
82
|
+
if (link.rel === 'preload' && link.as === undefined) {
|
|
83
|
+
return 'is a preload with no `as` — the browser ignores it and fetches nothing early';
|
|
84
|
+
}
|
|
85
|
+
if (link.rel === 'preload' && link.as === 'font' && link.crossorigin === undefined) {
|
|
86
|
+
return "is a font preload with no crossorigin — fonts are fetched in CORS mode, so the preload is not reused and the font downloads twice (crossorigin: 'anonymous')";
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const describe = (link: HeadLink): string => `link { rel: '${link.rel}', href: '${link.href}' }`;
|
|
92
|
+
|
|
93
|
+
/** The error a bad link is, at `file` when the caller knows the route. */
|
|
94
|
+
export function linkError(link: HeadLink, problem: string, file?: string): SeoError {
|
|
95
|
+
return linkInvalid(describe(link), problem, file);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The tags, in declaration order, attributes in a fixed order and only those given. Throws
|
|
100
|
+
* `X_SEO_LINK_INVALID` on the first bad link: this runs for every SSR request and every prerendered
|
|
101
|
+
* page, and a document carrying an unsafe href is worse than no document.
|
|
102
|
+
*/
|
|
103
|
+
export function linkTags(links: readonly HeadLink[]): HeadTag[] {
|
|
104
|
+
return links.map((link) => {
|
|
105
|
+
const problem = linkProblem(link);
|
|
106
|
+
if (problem !== undefined) throw linkError(link, problem);
|
|
107
|
+
const attrs: Record<string, string> = { rel: link.rel, href: link.href };
|
|
108
|
+
if (link.as !== undefined) attrs['as'] = link.as;
|
|
109
|
+
if (link.type !== undefined) attrs['type'] = link.type;
|
|
110
|
+
if (link.crossorigin !== undefined) attrs['crossorigin'] = link.crossorigin;
|
|
111
|
+
if (link.media !== undefined) attrs['media'] = link.media;
|
|
112
|
+
return { tag: 'link', attrs };
|
|
113
|
+
});
|
|
114
|
+
}
|
package/src/meta.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
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 { type HeadLink, linkTags } from './links';
|
|
5
6
|
import { hreflangTag, type MetaLocalization, ogLocaleTag } from './locale-tags';
|
|
6
7
|
import { absoluteUrl } from './xml';
|
|
7
8
|
|
|
@@ -82,6 +83,12 @@ export interface RouteMeta {
|
|
|
82
83
|
themeColor?: readonly ThemeColor[];
|
|
83
84
|
/** JSON-LD nodes from `ld.*`. Rendered as one script tag per node. */
|
|
84
85
|
ld?: readonly Readonly<Record<string, unknown>>[];
|
|
86
|
+
/**
|
|
87
|
+
* Extra `<link>` tags for `<head>`, in order: a font preload, a preconnect, a feed. An href is a
|
|
88
|
+
* path or an http(s) URL, a preload names `as`, a font preload names `crossorigin` — anything
|
|
89
|
+
* else is `X_SEO_LINK_INVALID`. Never absolutised: a preload must hit the origin the page did.
|
|
90
|
+
*/
|
|
91
|
+
links?: readonly HeadLink[];
|
|
85
92
|
}
|
|
86
93
|
|
|
87
94
|
export interface HeadTag {
|
|
@@ -303,6 +310,9 @@ export function renderMeta(meta: RouteMeta, options: RenderMetaOptions = {}): re
|
|
|
303
310
|
});
|
|
304
311
|
}
|
|
305
312
|
|
|
313
|
+
// --- the route's own links --------------------------------------------------
|
|
314
|
+
tags.push(...linkTags(meta.links ?? []));
|
|
315
|
+
|
|
306
316
|
// --- theme-color per colour scheme ----------------------------------------
|
|
307
317
|
for (const entry of meta.themeColor ?? []) {
|
|
308
318
|
tags.push({
|
package/src/validate.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
type SeoErrorCode,
|
|
15
15
|
titleTemplateSlotMissing,
|
|
16
16
|
} from './errors';
|
|
17
|
+
import { linkError, linkProblem } from './links';
|
|
17
18
|
import { applyTitleTemplate, DESCRIPTION_MAX_LENGTH, TITLE_MAX_LENGTH, TITLE_SLOT } from './meta';
|
|
18
19
|
import { indexableRoutes, isDynamic, type RouteRecord } from './routes';
|
|
19
20
|
import { absoluteUrl } from './xml';
|
|
@@ -128,6 +129,15 @@ export function validateMeta(
|
|
|
128
129
|
push(descriptions, meta.description, route.file);
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
// The same predicate the renderer throws on, reported here at the route FILE and before any
|
|
133
|
+
// request: an unsafe href in a static route's meta fails the build rather than a visitor.
|
|
134
|
+
for (const link of meta.links ?? []) {
|
|
135
|
+
const problem = linkProblem(link);
|
|
136
|
+
if (problem !== undefined) {
|
|
137
|
+
issues.push(issueOf(linkError(link, problem, route.file), route.path, route.file));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
131
141
|
// A canonical is only checkable for a static path: a dynamic route's
|
|
132
142
|
// canonical is produced per-item at render time.
|
|
133
143
|
if (meta.canonical !== undefined && options.baseUrl !== undefined && !isDynamic(route.path)) {
|