@ultimat3/core 27.15.0 → 27.16.1
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 +2 -2
- package/src/config-robots.ts +59 -0
- package/src/config-site.ts +30 -11
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/core",
|
|
3
|
-
"version": "27.
|
|
3
|
+
"version": "27.16.1",
|
|
4
4
|
"description": "Ultimate's foundation: errors, context, env, config, clock, ids, logging, telemetry, lifecycle",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -37,6 +37,6 @@
|
|
|
37
37
|
"test": "bun test"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@ultimat3/schema": "27.
|
|
40
|
+
"@ultimat3/schema": "27.16.1"
|
|
41
41
|
}
|
|
42
42
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Single responsibility: the refusals `seo.robots` earns — its `disallow` paths and each per-crawler
|
|
2
|
+
// group. Split from `config-site.ts`, which owns the shapes and the merge; this file only judges.
|
|
3
|
+
// `unknown` in throughout: an untyped config reaches here with whatever it wrote.
|
|
4
|
+
|
|
5
|
+
import { describeValue } from './error-render';
|
|
6
|
+
import { isJsonObject } from './json-object';
|
|
7
|
+
|
|
8
|
+
/** Every entry of a robots path list that is not a string starting with `/`. */
|
|
9
|
+
export function robotsPathIssues(label: string, paths: unknown): readonly string[] {
|
|
10
|
+
if (!Array.isArray(paths))
|
|
11
|
+
return [`${label} must be a list of paths, not ${describeValue(paths)}`];
|
|
12
|
+
const issues: string[] = [];
|
|
13
|
+
for (const path of paths as readonly unknown[]) {
|
|
14
|
+
if (typeof path !== 'string') {
|
|
15
|
+
issues.push(`${label} entry must be a path string, not ${describeValue(path)}`);
|
|
16
|
+
} else if (!path.startsWith('/')) {
|
|
17
|
+
issues.push(`${label} entry "${path}" must start with /`);
|
|
18
|
+
} else if (/[\r\n]/.test(path)) {
|
|
19
|
+
// A line break would write a directive of its own into `robots.txt`.
|
|
20
|
+
issues.push(`${label} entry ${JSON.stringify(path)} must not contain a line break`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return issues;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function userAgentIssues(label: string, userAgent: unknown): readonly string[] {
|
|
27
|
+
const agents: readonly unknown[] = Array.isArray(userAgent) ? userAgent : [userAgent];
|
|
28
|
+
if (agents.length === 0) return [`${label} must name at least one user agent`];
|
|
29
|
+
return agents.flatMap((agent) =>
|
|
30
|
+
typeof agent === 'string' && agent.trim() !== '' && !/[\r\n]/.test(agent)
|
|
31
|
+
? []
|
|
32
|
+
: [
|
|
33
|
+
`${label} must be a non-empty user-agent string (or a list of them), not ${describeValue(agent)}`,
|
|
34
|
+
],
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Every refusal `seo.robots.groups` earns, as sentences naming the group by its index. */
|
|
39
|
+
export function robotsGroupIssues(groups: unknown): readonly string[] {
|
|
40
|
+
if (!Array.isArray(groups)) {
|
|
41
|
+
return [`seo.robots.groups must be a list of groups, not ${describeValue(groups)}`];
|
|
42
|
+
}
|
|
43
|
+
return (groups as readonly unknown[]).flatMap((group, index) => {
|
|
44
|
+
const at = `seo.robots.groups[${index}]`;
|
|
45
|
+
if (!isJsonObject(group)) return [`${at} must be an object, not ${describeValue(group)}`];
|
|
46
|
+
const issues = [...userAgentIssues(`${at}.userAgent`, group['userAgent'])];
|
|
47
|
+
for (const key of ['allow', 'disallow'] as const) {
|
|
48
|
+
if (group[key] !== undefined) issues.push(...robotsPathIssues(`${at}.${key}`, group[key]));
|
|
49
|
+
}
|
|
50
|
+
const delay = group['crawlDelay'];
|
|
51
|
+
if (
|
|
52
|
+
delay !== undefined &&
|
|
53
|
+
!(typeof delay === 'number' && Number.isFinite(delay) && delay >= 0)
|
|
54
|
+
) {
|
|
55
|
+
issues.push(`${at}.crawlDelay must be a number of seconds ≥ 0, not ${describeValue(delay)}`);
|
|
56
|
+
}
|
|
57
|
+
return issues;
|
|
58
|
+
});
|
|
59
|
+
}
|
package/src/config-site.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Split from `config.ts` for `config-pwa.ts`' reason: that file sits at its 500-line ceiling.
|
|
4
4
|
|
|
5
5
|
import { type Input, layered } from './config-merge';
|
|
6
|
+
import { robotsGroupIssues, robotsPathIssues } from './config-robots';
|
|
6
7
|
import {
|
|
7
8
|
SITEMAP_PAGE_SIZE_MAX,
|
|
8
9
|
type SitemapSectionConfig,
|
|
@@ -20,9 +21,34 @@ export interface SiteConfig {
|
|
|
20
21
|
readonly origin: string | null;
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* One `User-agent` group of a production `robots.txt`. A crawler obeys the ONE group naming it most
|
|
26
|
+
* specifically and ignores `*` beside it, so a group for `GPTBot` replaces `*`'s rules for GPTBot.
|
|
27
|
+
*/
|
|
28
|
+
export interface SeoRobotsGroup {
|
|
29
|
+
/** `'GPTBot'`, or several sharing one set of rules: `['GPTBot', 'CCBot']`. Never empty. */
|
|
30
|
+
readonly userAgent: string | readonly string[];
|
|
31
|
+
/** Paths this group may fetch, each starting with `/`. */
|
|
32
|
+
readonly allow?: readonly string[] | undefined;
|
|
33
|
+
/** Paths this group may not fetch, each starting with `/`. `['/']` keeps it out entirely. */
|
|
34
|
+
readonly disallow?: readonly string[] | undefined;
|
|
35
|
+
/** Seconds between requests, for the crawlers that read `Crawl-delay`. */
|
|
36
|
+
readonly crawlDelay?: number | undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
23
39
|
export interface SeoRobotsConfig {
|
|
24
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Paths EVERY crawler is kept out of in production, e.g. `['/panel', '/api']` — added to every
|
|
42
|
+
* group, and left out of the sitemap. Each starts with `/`.
|
|
43
|
+
*/
|
|
25
44
|
readonly disallow: readonly string[];
|
|
45
|
+
/**
|
|
46
|
+
* Per-crawler groups. Empty (the default): one `User-agent: *` group allowing `/`. Declared, they
|
|
47
|
+
* ARE the file's groups — name `*` among them to give everyone else rules of their own.
|
|
48
|
+
* Optional in the TYPE only, so a `SeoRobotsConfig` literal written before it existed still
|
|
49
|
+
* compiles; `defineConfig` always fills it.
|
|
50
|
+
*/
|
|
51
|
+
readonly groups?: readonly SeoRobotsGroup[] | undefined;
|
|
26
52
|
}
|
|
27
53
|
|
|
28
54
|
/**
|
|
@@ -84,7 +110,7 @@ export function mergeSite(layers: readonly SiteSectionsInput[]): SiteSections {
|
|
|
84
110
|
),
|
|
85
111
|
seo: {
|
|
86
112
|
robots: layered<SeoRobotsConfig>(
|
|
87
|
-
{ disallow: [] },
|
|
113
|
+
{ disallow: [], groups: [] },
|
|
88
114
|
layers.map((layer) => layer.seo?.robots),
|
|
89
115
|
),
|
|
90
116
|
sitemap: layered<SeoSitemapConfig>(
|
|
@@ -122,15 +148,8 @@ export function siteIssues(config: SiteSections, issues: string[]): void {
|
|
|
122
148
|
const issue = originIssue(origin);
|
|
123
149
|
if (issue !== undefined) issues.push(issue);
|
|
124
150
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
for (const path of config.seo.robots.disallow as readonly unknown[]) {
|
|
128
|
-
if (typeof path !== 'string') {
|
|
129
|
-
issues.push(`seo.robots.disallow entry must be a path string, not ${describeValue(path)}`);
|
|
130
|
-
} else if (!path.startsWith('/')) {
|
|
131
|
-
issues.push(`seo.robots.disallow entry "${path}" must start with /`);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
151
|
+
issues.push(...robotsPathIssues('seo.robots.disallow', config.seo.robots.disallow));
|
|
152
|
+
issues.push(...robotsGroupIssues(config.seo.robots.groups ?? []));
|
|
134
153
|
for (const path of config.seo.sitemap.extra as readonly unknown[]) {
|
|
135
154
|
if (typeof path !== 'string') {
|
|
136
155
|
issues.push(`seo.sitemap.extra entry must be a path string, not ${describeValue(path)}`);
|