@ultimat3/core 27.14.1 → 27.16.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/core",
3
- "version": "27.14.1",
3
+ "version": "27.16.0",
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.14.1"
40
+ "@ultimat3/schema": "27.16.0"
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
+ }
@@ -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
- /** Paths `robots.txt` disallows in production, e.g. `['/panel', '/api']`. Each starts with `/`. */
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
- // `unknown` entries: an untyped config reaches here with whatever it listed, and `5.startsWith`
126
- // was a native `TypeError` thrown by the validator itself.
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)}`);
package/src/index.ts CHANGED
@@ -194,6 +194,7 @@ export type {
194
194
  SeoConfig,
195
195
  SeoConfigInput,
196
196
  SeoRobotsConfig,
197
+ SeoRobotsGroup,
197
198
  SeoSitemapConfig,
198
199
  SiteConfig,
199
200
  SitemapLastmod,