@ultimat3/seo 1.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/LICENSE +21 -0
- package/README.md +149 -0
- package/package.json +35 -0
- package/src/budgets.ts +127 -0
- package/src/errors.ts +177 -0
- package/src/image-driver.ts +91 -0
- package/src/images.ts +235 -0
- package/src/index.ts +121 -0
- package/src/ld.ts +325 -0
- package/src/meta.ts +269 -0
- package/src/robots.ts +79 -0
- package/src/routes.ts +62 -0
- package/src/rss.ts +178 -0
- package/src/sitemap.ts +179 -0
- package/src/validate.ts +146 -0
- package/src/xml.ts +39 -0
package/src/sitemap.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Sitemap generation from the route table. Dynamic routes contribute the URLs
|
|
2
|
+
// their `prerender()` enumerates, so the sitemap can never drift from what the
|
|
3
|
+
// build actually produced. Splits into an index past the 50,000-URL protocol cap.
|
|
4
|
+
|
|
5
|
+
import { sitemapTooLarge } from './errors';
|
|
6
|
+
import { type ChangeFreq, expandRoute, indexableRoutes, type RouteRecord } from './routes';
|
|
7
|
+
import { absoluteUrl, attributes, escapeXml } from './xml';
|
|
8
|
+
|
|
9
|
+
/** Both limits are from the sitemaps.org protocol. */
|
|
10
|
+
export const SITEMAP_MAX_URLS = 50_000;
|
|
11
|
+
export const SITEMAP_INDEX_MAX_FILES = 50_000;
|
|
12
|
+
|
|
13
|
+
export interface SitemapAlternate {
|
|
14
|
+
hreflang: string;
|
|
15
|
+
href: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SitemapUrl {
|
|
19
|
+
loc: string;
|
|
20
|
+
lastmod?: string;
|
|
21
|
+
changefreq?: ChangeFreq;
|
|
22
|
+
priority?: number;
|
|
23
|
+
alternates?: readonly SitemapAlternate[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SitemapFile {
|
|
27
|
+
/** Path relative to the site root, e.g. `/sitemap-1.xml`. */
|
|
28
|
+
path: string;
|
|
29
|
+
xml: string;
|
|
30
|
+
urlCount: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SitemapResult {
|
|
34
|
+
readonly files: readonly SitemapFile[];
|
|
35
|
+
/** Present only when the URLs did not fit in a single file. */
|
|
36
|
+
readonly index: SitemapFile | undefined;
|
|
37
|
+
readonly urlCount: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface BuildSitemapOptions {
|
|
41
|
+
baseUrl: string;
|
|
42
|
+
/** Locales to emit `xhtml:link` alternates for. */
|
|
43
|
+
locales?: readonly string[];
|
|
44
|
+
/** Defaults to `/{locale}{path}`. */
|
|
45
|
+
localizePath?: (path: string, locale: string) => string;
|
|
46
|
+
/** The locale whose URLs are unprefixed and become `x-default`. */
|
|
47
|
+
defaultLocale?: string;
|
|
48
|
+
maxUrls?: number;
|
|
49
|
+
lastmod?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function localize(path: string, locale: string, options: BuildSitemapOptions): string {
|
|
53
|
+
if (locale === options.defaultLocale) return path;
|
|
54
|
+
const fn = options.localizePath ?? ((p: string, l: string) => `/${l}${p === '/' ? '' : p}`);
|
|
55
|
+
return fn(path, locale);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Every concrete URL the route table produces, with per-locale alternates. */
|
|
59
|
+
export async function sitemapUrls(
|
|
60
|
+
routes: readonly RouteRecord[],
|
|
61
|
+
options: BuildSitemapOptions,
|
|
62
|
+
): Promise<readonly SitemapUrl[]> {
|
|
63
|
+
const urls: SitemapUrl[] = [];
|
|
64
|
+
const locales = options.locales ?? [];
|
|
65
|
+
|
|
66
|
+
for (const route of indexableRoutes(routes)) {
|
|
67
|
+
for (const path of await expandRoute(route)) {
|
|
68
|
+
const alternates: SitemapAlternate[] = locales.map((locale) => ({
|
|
69
|
+
hreflang: locale,
|
|
70
|
+
href: absoluteUrl(options.baseUrl, localize(path, locale, options)),
|
|
71
|
+
}));
|
|
72
|
+
if (alternates.length > 0) {
|
|
73
|
+
alternates.push({
|
|
74
|
+
hreflang: 'x-default',
|
|
75
|
+
href: absoluteUrl(options.baseUrl, path),
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const lastmod = route.lastmod ?? options.lastmod;
|
|
80
|
+
const emitFor =
|
|
81
|
+
locales.length === 0 ? [path] : locales.map((l) => localize(path, l, options));
|
|
82
|
+
for (const localised of emitFor) {
|
|
83
|
+
urls.push({
|
|
84
|
+
loc: absoluteUrl(options.baseUrl, localised),
|
|
85
|
+
...(lastmod === undefined ? {} : { lastmod }),
|
|
86
|
+
...(route.changefreq === undefined ? {} : { changefreq: route.changefreq }),
|
|
87
|
+
...(route.priority === undefined ? {} : { priority: route.priority }),
|
|
88
|
+
...(alternates.length === 0 ? {} : { alternates }),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return urls;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function renderUrlSet(urls: readonly SitemapUrl[]): string {
|
|
97
|
+
const needsXhtml = urls.some((url) => (url.alternates?.length ?? 0) > 0);
|
|
98
|
+
const ns = needsXhtml
|
|
99
|
+
? ' xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml"'
|
|
100
|
+
: ' xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"';
|
|
101
|
+
const body = urls
|
|
102
|
+
.map((url) => {
|
|
103
|
+
const parts = [` <loc>${escapeXml(url.loc)}</loc>`];
|
|
104
|
+
if (url.lastmod !== undefined) parts.push(` <lastmod>${escapeXml(url.lastmod)}</lastmod>`);
|
|
105
|
+
if (url.changefreq !== undefined) {
|
|
106
|
+
parts.push(` <changefreq>${url.changefreq}</changefreq>`);
|
|
107
|
+
}
|
|
108
|
+
if (url.priority !== undefined) {
|
|
109
|
+
parts.push(` <priority>${url.priority.toFixed(1)}</priority>`);
|
|
110
|
+
}
|
|
111
|
+
for (const alternate of url.alternates ?? []) {
|
|
112
|
+
parts.push(
|
|
113
|
+
` <xhtml:link${attributes({ rel: 'alternate', hreflang: alternate.hreflang, href: alternate.href })}/>`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return ` <url>\n${parts.join('\n')}\n </url>`;
|
|
117
|
+
})
|
|
118
|
+
.join('\n');
|
|
119
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset${ns}>\n${body}\n</urlset>\n`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function renderIndex(files: readonly SitemapFile[], options: BuildSitemapOptions): string {
|
|
123
|
+
const body = files
|
|
124
|
+
.map((file) => {
|
|
125
|
+
const loc = escapeXml(absoluteUrl(options.baseUrl, file.path));
|
|
126
|
+
const lastmod =
|
|
127
|
+
options.lastmod === undefined
|
|
128
|
+
? ''
|
|
129
|
+
: `\n <lastmod>${escapeXml(options.lastmod)}</lastmod>`;
|
|
130
|
+
return ` <sitemap>\n <loc>${loc}</loc>${lastmod}\n </sitemap>`;
|
|
131
|
+
})
|
|
132
|
+
.join('\n');
|
|
133
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</sitemapindex>\n`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function chunk<T>(items: readonly T[], size: number): T[][] {
|
|
137
|
+
const out: T[][] = [];
|
|
138
|
+
for (let index = 0; index < items.length; index += size) {
|
|
139
|
+
out.push(items.slice(index, index + size));
|
|
140
|
+
}
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function buildSitemap(
|
|
145
|
+
routes: readonly RouteRecord[],
|
|
146
|
+
options: BuildSitemapOptions,
|
|
147
|
+
): Promise<SitemapResult> {
|
|
148
|
+
const urls = await sitemapUrls(routes, options);
|
|
149
|
+
const maxUrls = options.maxUrls ?? SITEMAP_MAX_URLS;
|
|
150
|
+
|
|
151
|
+
if (urls.length <= maxUrls) {
|
|
152
|
+
return {
|
|
153
|
+
files: [{ path: '/sitemap.xml', xml: renderUrlSet(urls), urlCount: urls.length }],
|
|
154
|
+
index: undefined,
|
|
155
|
+
urlCount: urls.length,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const groups = chunk(urls, maxUrls);
|
|
160
|
+
if (groups.length > SITEMAP_INDEX_MAX_FILES) {
|
|
161
|
+
throw sitemapTooLarge(groups.length, SITEMAP_INDEX_MAX_FILES);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const files: SitemapFile[] = groups.map((group, position) => ({
|
|
165
|
+
path: `/sitemap-${position + 1}.xml`,
|
|
166
|
+
xml: renderUrlSet(group),
|
|
167
|
+
urlCount: group.length,
|
|
168
|
+
}));
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
files,
|
|
172
|
+
index: {
|
|
173
|
+
path: '/sitemap.xml',
|
|
174
|
+
xml: renderIndex(files, options),
|
|
175
|
+
urlCount: files.length,
|
|
176
|
+
},
|
|
177
|
+
urlCount: urls.length,
|
|
178
|
+
};
|
|
179
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// The build gate. A `site/` route without a title or description does not warn —
|
|
2
|
+
// it throws X_SEO_META_MISSING naming the exact file and the exact edit. Also
|
|
3
|
+
// catches the three failures that only show up weeks later in Search Console:
|
|
4
|
+
// duplicate meta, an over-length title, and a canonical that lies.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
canonicalMismatch,
|
|
8
|
+
duplicateMeta,
|
|
9
|
+
metaMissing,
|
|
10
|
+
metaTooLong,
|
|
11
|
+
SeoError,
|
|
12
|
+
type SeoErrorCode,
|
|
13
|
+
} from './errors';
|
|
14
|
+
import { applyTitleTemplate, DESCRIPTION_MAX_LENGTH, TITLE_MAX_LENGTH } from './meta';
|
|
15
|
+
import { indexableRoutes, isDynamic, type RouteRecord } from './routes';
|
|
16
|
+
import { absoluteUrl } from './xml';
|
|
17
|
+
|
|
18
|
+
export interface MetaIssue {
|
|
19
|
+
readonly code: string;
|
|
20
|
+
readonly route: string;
|
|
21
|
+
readonly file: string;
|
|
22
|
+
readonly cause: string;
|
|
23
|
+
readonly fix: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** `--json`-shaped: the CLI prints this verbatim under `x verify --json`. */
|
|
27
|
+
export interface MetaValidationReport {
|
|
28
|
+
readonly ok: boolean;
|
|
29
|
+
readonly checked: number;
|
|
30
|
+
readonly issues: readonly MetaIssue[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ValidateMetaOptions {
|
|
34
|
+
/** Required to check canonicals. Without it, canonical checks are skipped. */
|
|
35
|
+
baseUrl?: string;
|
|
36
|
+
titleMaxLength?: number;
|
|
37
|
+
descriptionMaxLength?: number;
|
|
38
|
+
/** Report duplicates across routes. On by default. */
|
|
39
|
+
checkDuplicates?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function issueOf(error: SeoError, route: string, file: string): MetaIssue {
|
|
43
|
+
return { code: error.code, route, file, cause: error.cause, fix: error.fix };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function validateMeta(
|
|
47
|
+
routes: readonly RouteRecord[],
|
|
48
|
+
options: ValidateMetaOptions = {},
|
|
49
|
+
): MetaValidationReport {
|
|
50
|
+
const titleMax = options.titleMaxLength ?? TITLE_MAX_LENGTH;
|
|
51
|
+
const descriptionMax = options.descriptionMaxLength ?? DESCRIPTION_MAX_LENGTH;
|
|
52
|
+
const checked = indexableRoutes(routes);
|
|
53
|
+
const issues: MetaIssue[] = [];
|
|
54
|
+
|
|
55
|
+
const titles = new Map<string, string[]>();
|
|
56
|
+
const descriptions = new Map<string, string[]>();
|
|
57
|
+
|
|
58
|
+
for (const route of checked) {
|
|
59
|
+
const meta = route.meta ?? {};
|
|
60
|
+
|
|
61
|
+
if (meta.title === undefined || meta.title.trim() === '') {
|
|
62
|
+
issues.push(issueOf(metaMissing(route.file, route.path, 'title'), route.path, route.file));
|
|
63
|
+
} else {
|
|
64
|
+
const rendered = applyTitleTemplate(meta.title, meta.titleTemplate);
|
|
65
|
+
if (rendered.length > titleMax) {
|
|
66
|
+
issues.push(
|
|
67
|
+
issueOf(
|
|
68
|
+
metaTooLong(route.file, 'title', rendered.length, titleMax),
|
|
69
|
+
route.path,
|
|
70
|
+
route.file,
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
push(titles, rendered, route.file);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (meta.description === undefined || meta.description.trim() === '') {
|
|
78
|
+
issues.push(
|
|
79
|
+
issueOf(metaMissing(route.file, route.path, 'description'), route.path, route.file),
|
|
80
|
+
);
|
|
81
|
+
} else {
|
|
82
|
+
if (meta.description.length > descriptionMax) {
|
|
83
|
+
issues.push(
|
|
84
|
+
issueOf(
|
|
85
|
+
metaTooLong(route.file, 'description', meta.description.length, descriptionMax),
|
|
86
|
+
route.path,
|
|
87
|
+
route.file,
|
|
88
|
+
),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
push(descriptions, meta.description, route.file);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A canonical is only checkable for a static path: a dynamic route's
|
|
95
|
+
// canonical is produced per-item at render time.
|
|
96
|
+
if (meta.canonical !== undefined && options.baseUrl !== undefined && !isDynamic(route.path)) {
|
|
97
|
+
const expected = absoluteUrl(options.baseUrl, route.path);
|
|
98
|
+
const actual = absoluteUrl(options.baseUrl, meta.canonical);
|
|
99
|
+
if (actual !== expected) {
|
|
100
|
+
issues.push(
|
|
101
|
+
issueOf(canonicalMismatch(route.file, actual, expected), route.path, route.file),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (options.checkDuplicates !== false) {
|
|
108
|
+
issues.push(...duplicates(titles, 'title'), ...duplicates(descriptions, 'description'));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { ok: issues.length === 0, checked: checked.length, issues };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function push(index: Map<string, string[]>, key: string, file: string): void {
|
|
115
|
+
const existing = index.get(key);
|
|
116
|
+
if (existing === undefined) index.set(key, [file]);
|
|
117
|
+
else existing.push(file);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function duplicates(index: Map<string, string[]>, field: string): MetaIssue[] {
|
|
121
|
+
const out: MetaIssue[] = [];
|
|
122
|
+
for (const [value, files] of index) {
|
|
123
|
+
if (files.length < 2) continue;
|
|
124
|
+
const error = duplicateMeta(field, value, files);
|
|
125
|
+
out.push({
|
|
126
|
+
code: error.code,
|
|
127
|
+
route: files.join(', '),
|
|
128
|
+
file: files[0] ?? '',
|
|
129
|
+
cause: error.cause,
|
|
130
|
+
fix: error.fix,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Fails the build on the first issue. `x verify` calls this. */
|
|
137
|
+
export function assertMeta(report: MetaValidationReport): void {
|
|
138
|
+
const first = report.issues[0];
|
|
139
|
+
if (first === undefined) return;
|
|
140
|
+
throw new SeoError({
|
|
141
|
+
code: first.code as SeoErrorCode,
|
|
142
|
+
cause: first.cause,
|
|
143
|
+
fix: first.fix,
|
|
144
|
+
meta: { route: first.route, file: first.file, totalIssues: report.issues.length },
|
|
145
|
+
});
|
|
146
|
+
}
|
package/src/xml.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// XML/HTML escaping and tag emission. One implementation, because a sitemap, a
|
|
2
|
+
// feed, and a <head> tag all fail the same way on an unescaped ampersand.
|
|
3
|
+
|
|
4
|
+
const XML_ESCAPES: Readonly<Record<string, string>> = {
|
|
5
|
+
'&': '&',
|
|
6
|
+
'<': '<',
|
|
7
|
+
'>': '>',
|
|
8
|
+
'"': '"',
|
|
9
|
+
"'": ''',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function escapeXml(value: string): string {
|
|
13
|
+
return value.replace(/[&<>"']/g, (char) => XML_ESCAPES[char] ?? char);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Attribute values only ever need these three; apostrophes stay readable. */
|
|
17
|
+
export function escapeAttribute(value: string): string {
|
|
18
|
+
return value.replace(/[&<>"]/g, (char) => XML_ESCAPES[char] ?? char);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function xmlElement(name: string, text: string): string {
|
|
22
|
+
return `<${name}>${escapeXml(text)}</${name}>`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function cdata(value: string): string {
|
|
26
|
+
return `<![CDATA[${value.replaceAll(']]>', ']]]]><![CDATA[>')}]]>`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function attributes(attrs: Readonly<Record<string, string>>): string {
|
|
30
|
+
return Object.entries(attrs)
|
|
31
|
+
.map(([key, value]) => ` ${key}="${escapeAttribute(value)}"`)
|
|
32
|
+
.join('');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Join a base URL and a path without producing `//` or dropping a segment. */
|
|
36
|
+
export function absoluteUrl(baseUrl: string, path: string): string {
|
|
37
|
+
if (/^https?:\/\//.test(path)) return path;
|
|
38
|
+
return `${baseUrl.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`.replace(/\/$/, '') || baseUrl;
|
|
39
|
+
}
|