@ultimat3/core 27.8.0 → 27.9.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.8.0",
3
+ "version": "27.9.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.8.0"
40
+ "@ultimat3/schema": "27.9.0"
41
41
  }
42
42
  }
@@ -3,6 +3,11 @@
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 {
7
+ SITEMAP_PAGE_SIZE_MAX,
8
+ type SitemapSectionConfig,
9
+ sitemapSectionIssues,
10
+ } from './config-sitemap';
6
11
  import { describeValue } from './error-render';
7
12
 
8
13
  export interface SiteConfig {
@@ -39,6 +44,14 @@ export interface SeoSitemapConfig {
39
44
  */
40
45
  readonly extra: readonly string[];
41
46
  readonly lastmod: SitemapLastmod;
47
+ /**
48
+ * Named parts of a sitemap index. Empty (the default): one `/sitemap.xml` urlset, split only past
49
+ * a cap. Any section declared: `/sitemap.xml` is a `<sitemapindex>` of `/sitemaps/<name>-<n>.xml`,
50
+ * the pages no section matches under `pages`.
51
+ */
52
+ readonly sections: readonly SitemapSectionConfig[];
53
+ /** `<url>` entries per sitemap file. Default and ceiling: 50,000, the sitemaps.org cap. */
54
+ readonly pageSize: number;
42
55
  }
43
56
 
44
57
  export interface SeoConfig {
@@ -75,7 +88,7 @@ export function mergeSite(layers: readonly SiteSectionsInput[]): SiteSections {
75
88
  layers.map((layer) => layer.seo?.robots),
76
89
  ),
77
90
  sitemap: layered<SeoSitemapConfig>(
78
- { extra: [], lastmod: 'none' },
91
+ { extra: [], lastmod: 'none', sections: [], pageSize: SITEMAP_PAGE_SIZE_MAX },
79
92
  layers.map((layer) => layer.seo?.sitemap),
80
93
  ),
81
94
  },
@@ -136,4 +149,5 @@ export function siteIssues(config: SiteSections, issues: string[]): void {
136
149
  `seo.sitemap.lastmod "${String(config.seo.sitemap.lastmod)}" must be one of ${SITEMAP_LASTMOD_SOURCES.join(', ')}`,
137
150
  );
138
151
  }
152
+ issues.push(...sitemapSectionIssues(config.seo.sitemap.sections, config.seo.sitemap.pageSize));
139
153
  }
@@ -0,0 +1,118 @@
1
+ // Single responsibility: `seo.sitemap.sections` and `seo.sitemap.pageSize` — the shape of a named
2
+ // sitemap section and the one validator both `defineConfig` and `@ultimat3/seo` refuse it with.
3
+ // Split from `config-site.ts`: that file owns the origin and the crawler rules.
4
+
5
+ import { describeValue } from './error-render';
6
+
7
+ /**
8
+ * One named part of a sitemap index. Every listed page whose UNPREFIXED path matches goes to
9
+ * `/sitemaps/<name>-<n>.xml`; the first section that matches wins, in declaration order.
10
+ */
11
+ export interface SitemapSectionConfig {
12
+ /** Lower-case slug — it is a file name: `blog` → `/sitemaps/blog-1.xml`. */
13
+ readonly name: string;
14
+ /**
15
+ * A path pattern, or several: `/precios` (that page), `/blog/*` (one segment below), `/blog/**`
16
+ * (`/blog` and everything below it). Matched against the page's path without its locale prefix,
17
+ * so `/en/blog/a` files with `/blog/a`.
18
+ */
19
+ readonly match: string | readonly string[];
20
+ }
21
+
22
+ /** Where a page no section matches goes. Reserved: an app's section may not take the name. */
23
+ export const SITEMAP_DEFAULT_SECTION = 'pages';
24
+
25
+ /** The sitemaps.org cap on `<url>` entries in one file — the default and the ceiling of `pageSize`. */
26
+ export const SITEMAP_PAGE_SIZE_MAX = 50_000;
27
+
28
+ const SECTION_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
29
+ const SECTION_NAME_MAX = 64;
30
+
31
+ /** The patterns of one section as a list, whichever form it was written in. */
32
+ export function sectionPatterns(section: SitemapSectionConfig): readonly string[] {
33
+ return typeof section.match === 'string' ? [section.match] : section.match;
34
+ }
35
+
36
+ function patternIssue(pattern: string): string | undefined {
37
+ if (!pattern.startsWith('/') || pattern.startsWith('//') || /[?#]/.test(pattern)) {
38
+ return 'must be a path starting with / — no origin, query or fragment';
39
+ }
40
+ const segments = pattern.split('/').slice(1);
41
+ for (const [index, segment] of segments.entries()) {
42
+ if (segment === '**' && index !== segments.length - 1) {
43
+ return 'may use ** only as its last segment';
44
+ }
45
+ if (segment !== '*' && segment !== '**' && segment.includes('*')) {
46
+ return 'may use * only as a whole segment';
47
+ }
48
+ }
49
+ return undefined;
50
+ }
51
+
52
+ /**
53
+ * Every refusal `sections` and `pageSize` earn, as sentences. `unknown` in: an untyped config
54
+ * reaches here with whatever it wrote, and a validator that throws natively names nothing.
55
+ */
56
+ export function sitemapSectionIssues(sections: unknown, pageSize: unknown): readonly string[] {
57
+ const issues: string[] = [];
58
+ if (
59
+ typeof pageSize !== 'number' ||
60
+ !Number.isSafeInteger(pageSize) ||
61
+ pageSize < 1 ||
62
+ pageSize > SITEMAP_PAGE_SIZE_MAX
63
+ ) {
64
+ issues.push(
65
+ `seo.sitemap.pageSize must be a whole number from 1 to ${String(SITEMAP_PAGE_SIZE_MAX)} (the sitemaps.org cap), not ${describeValue(pageSize)}`,
66
+ );
67
+ }
68
+ if (!Array.isArray(sections)) {
69
+ issues.push(
70
+ `seo.sitemap.sections must be a list of { name, match }, not ${describeValue(sections)}`,
71
+ );
72
+ return issues;
73
+ }
74
+ const seen = new Set<string>();
75
+ for (const section of sections as readonly unknown[]) {
76
+ if (typeof section !== 'object' || section === null || Array.isArray(section)) {
77
+ issues.push(
78
+ `seo.sitemap.sections entry must be { name, match }, not ${describeValue(section)}`,
79
+ );
80
+ continue;
81
+ }
82
+ const { name, match } = section as { readonly name?: unknown; readonly match?: unknown };
83
+ if (typeof name !== 'string' || !SECTION_NAME.test(name) || name.length > SECTION_NAME_MAX) {
84
+ issues.push(
85
+ `seo.sitemap.sections name ${describeValue(name)} must be a lower-case slug (a-z, 0-9, single hyphens, at most ${String(SECTION_NAME_MAX)} characters) — it becomes the file name /sitemaps/<name>-<n>.xml`,
86
+ );
87
+ } else if (name === SITEMAP_DEFAULT_SECTION) {
88
+ issues.push(
89
+ `seo.sitemap.sections name "${name}" is reserved for the pages no section matches`,
90
+ );
91
+ } else if (seen.has(name)) {
92
+ issues.push(`seo.sitemap.sections names "${name}" twice — two sections would write one file`);
93
+ } else {
94
+ seen.add(name);
95
+ }
96
+ const label = typeof name === 'string' ? `"${name}"` : 'entry';
97
+ const patterns = typeof match === 'string' ? [match] : match;
98
+ if (!Array.isArray(patterns) || patterns.length === 0) {
99
+ issues.push(
100
+ `seo.sitemap.sections ${label} match must be a path pattern or a non-empty list of them, not ${describeValue(match)}`,
101
+ );
102
+ continue;
103
+ }
104
+ for (const pattern of patterns as readonly unknown[]) {
105
+ if (typeof pattern !== 'string') {
106
+ issues.push(
107
+ `seo.sitemap.sections ${label} match entry must be a path pattern string, not ${describeValue(pattern)}`,
108
+ );
109
+ continue;
110
+ }
111
+ const issue = patternIssue(pattern);
112
+ if (issue !== undefined) {
113
+ issues.push(`seo.sitemap.sections ${label} match "${pattern}" ${issue}`);
114
+ }
115
+ }
116
+ }
117
+ return issues;
118
+ }
package/src/index.ts CHANGED
@@ -195,6 +195,13 @@ export type {
195
195
  SiteSectionsInput,
196
196
  } from './config-site';
197
197
  export { SITEMAP_LASTMOD_SOURCES } from './config-site';
198
+ export type { SitemapSectionConfig } from './config-sitemap';
199
+ export {
200
+ SITEMAP_DEFAULT_SECTION,
201
+ SITEMAP_PAGE_SIZE_MAX,
202
+ sectionPatterns,
203
+ sitemapSectionIssues,
204
+ } from './config-sitemap';
198
205
  export type { ConflictPolicy, ResolveConflictOptions, Row } from './conflict-policy';
199
206
  export { resolveConflict } from './conflict-policy';
200
207
  export type { Ctx, CtxFacts, CtxInit, CtxPatch, CtxServices, ServiceBag } from './context';