@ultimat3/core 22.2.1 → 22.3.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": "22.2.1",
3
+ "version": "22.3.0",
4
4
  "description": "Ultimate's foundation: errors, context, env, config, clock, ids, logging, telemetry, lifecycle",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -40,6 +40,6 @@
40
40
  "test": "bun test"
41
41
  },
42
42
  "dependencies": {
43
- "@ultimat3/schema": "22.2.1"
43
+ "@ultimat3/schema": "22.3.0"
44
44
  }
45
45
  }
@@ -0,0 +1,87 @@
1
+ // Single responsibility: the `site` and `seo` blocks of `app.config.ts` — the public origin every
2
+ // absolute URL a document carries is built from, and the paths `robots.txt` keeps crawlers out of.
3
+ // Split from `config.ts` for `config-pwa.ts`' reason: that file sits at its 500-line ceiling.
4
+
5
+ import { type Input, layered } from './config-merge';
6
+
7
+ export interface SiteConfig {
8
+ /**
9
+ * `https://www.example.com` — canonical, `og:url`, hreflang and the sitemap are absolute against
10
+ * it. `null` falls back to `APP_URL`, then to the request's own origin; a production build with
11
+ * neither warns, because a relative canonical is one a search engine may resolve against a CDN
12
+ * host or a preview domain.
13
+ */
14
+ readonly origin: string | null;
15
+ }
16
+
17
+ export interface SeoRobotsConfig {
18
+ /** Paths `robots.txt` disallows in production, e.g. `['/panel', '/api']`. Each starts with `/`. */
19
+ readonly disallow: readonly string[];
20
+ }
21
+
22
+ export interface SeoConfig {
23
+ readonly robots: SeoRobotsConfig;
24
+ }
25
+
26
+ /** `robots` is NESTED for `AiConfigInput`'s reason: `section` applies a patch one level deep. */
27
+ export interface SeoConfigInput {
28
+ readonly robots?: Input<SeoRobotsConfig> | undefined;
29
+ }
30
+
31
+ export interface SiteSections {
32
+ readonly site: SiteConfig;
33
+ readonly seo: SeoConfig;
34
+ }
35
+
36
+ export interface SiteSectionsInput {
37
+ readonly site?: Input<SiteConfig> | undefined;
38
+ readonly seo?: SeoConfigInput | undefined;
39
+ }
40
+
41
+ /** Both sections, every layer applied key by key over the defaults. */
42
+ export function mergeSite(layers: readonly SiteSectionsInput[]): SiteSections {
43
+ return {
44
+ site: layered<SiteConfig>(
45
+ { origin: null },
46
+ layers.map((layer) => layer.site),
47
+ ),
48
+ seo: {
49
+ robots: layered<SeoRobotsConfig>(
50
+ { disallow: [] },
51
+ layers.map((layer) => layer.seo?.robots),
52
+ ),
53
+ },
54
+ };
55
+ }
56
+
57
+ /**
58
+ * An origin is scheme + host (+ port) and nothing else: a path would be doubled into every URL built
59
+ * against it, and a scheme other than http(s) is not a page a crawler can fetch.
60
+ */
61
+ function originIssue(origin: string): string | undefined {
62
+ let url: URL;
63
+ try {
64
+ url = new URL(origin);
65
+ } catch {
66
+ return `site.origin "${origin}" is not an absolute URL`;
67
+ }
68
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
69
+ return `site.origin "${origin}" must be http:// or https://`;
70
+ }
71
+ if (url.pathname !== '/' || url.search !== '' || url.hash !== '') {
72
+ return `site.origin "${origin}" must be an origin only — no path, query or fragment`;
73
+ }
74
+ return undefined;
75
+ }
76
+
77
+ /** Appends every refusal the two sections earn to `issues`, `config.ts`' one list. */
78
+ export function siteIssues(config: SiteSections, issues: string[]): void {
79
+ const origin = config.site.origin;
80
+ if (origin !== null) {
81
+ const issue = originIssue(origin);
82
+ if (issue !== undefined) issues.push(issue);
83
+ }
84
+ for (const path of config.seo.robots.disallow) {
85
+ if (!path.startsWith('/')) issues.push(`seo.robots.disallow entry "${path}" must start with /`);
86
+ }
87
+ }
package/src/config.ts CHANGED
@@ -12,6 +12,8 @@ import { BASE_FIX, CACHE_TIER_FIX, TIMEZONE_FIX } from './config-fixes';
12
12
  import { type Input, lastSaid, layered } from './config-merge';
13
13
  import type { PwaConfig, PwaOfflineConfig } from './config-pwa';
14
14
  import { PWA_FIX, pwaIssues } from './config-pwa';
15
+ import type { SeoConfig, SiteConfig, SiteSectionsInput } from './config-site';
16
+ import { mergeSite, siteIssues } from './config-site';
15
17
  import { describeValue } from './error-render';
16
18
  import { ConfigInvalidError } from './errors';
17
19
  import { defaultReadinessGraceMs, readinessGraceIssue } from './lifecycle-grace';
@@ -212,6 +214,8 @@ export interface AppConfig {
212
214
  readonly notify: NotifyConfig;
213
215
  readonly ai: AiConfig;
214
216
  readonly drain: DrainConfig;
217
+ readonly site: SiteConfig;
218
+ readonly seo: SeoConfig;
215
219
  }
216
220
 
217
221
  /** `mcp` is the only member, and it is NESTED — `Input<AiConfig>` would make it all-or-nothing. */
@@ -230,7 +234,7 @@ export interface PwaConfigInput extends Omit<Input<PwaConfig>, 'offline'> {
230
234
  readonly offline?: Input<PwaOfflineConfig> | undefined;
231
235
  }
232
236
 
233
- export interface AppConfigInput {
237
+ export interface AppConfigInput extends SiteSectionsInput {
234
238
  readonly name: string;
235
239
  readonly locales?: readonly string[] | undefined;
236
240
  readonly defaultLocale?: string | undefined;
@@ -271,7 +275,7 @@ function isLocale(value: string): boolean {
271
275
  }
272
276
  }
273
277
 
274
- function defaults(name: string): Omit<AppConfig, 'name'> {
278
+ function defaults(name: string): Omit<AppConfig, 'name' | 'site' | 'seo'> {
275
279
  return {
276
280
  locales: ['en'],
277
281
  defaultLocale: 'en',
@@ -380,6 +384,7 @@ function validate(config: AppConfig): void {
380
384
  // remedy, because `pwa.enabled` turning four other requirements on is a question about that block
381
385
  // and nothing else here.
382
386
  if (pwaIssues(config.pwa, issues)) pwaFix.push(PWA_FIX);
387
+ siteIssues(config, issues);
383
388
 
384
389
  // A rung the ladder cannot build is the defect this key had: `sortTiers` places a name by its
385
390
  // index in `CACHE_TIERS`, and a name missing from it sorts to `-1` — AHEAD of the request memo.
@@ -487,6 +492,7 @@ export function defineConfig(
487
492
  base.drain,
488
493
  layers.map((layer) => layer.drain),
489
494
  ),
495
+ ...mergeSite(layers),
490
496
  };
491
497
 
492
498
  validate(config);
package/src/index.ts CHANGED
@@ -111,6 +111,14 @@ export type {
111
111
  export { defineConfig, INBOX_RETENTION_KEYS } from './config';
112
112
  export type { PwaColors, PwaConfig, PwaOfflineConfig, PwaSchemeColors } from './config-pwa';
113
113
  export { PWA_COLOR_KEYS, PWA_SCHEMES } from './config-pwa';
114
+ export type {
115
+ SeoConfig,
116
+ SeoConfigInput,
117
+ SeoRobotsConfig,
118
+ SiteConfig,
119
+ SiteSections,
120
+ SiteSectionsInput,
121
+ } from './config-site';
114
122
  export type { ConflictPolicy, ResolveConflictOptions, Row } from './conflict-policy';
115
123
  export { resolveConflict } from './conflict-policy';
116
124
  export type { Ctx, CtxFacts, CtxInit, CtxPatch, CtxServices, ServiceBag } from './context';
@@ -536,6 +544,8 @@ export { installSignalHandlers } from './lifecycle-signals';
536
544
  export { isSelfOrigin, listeningOrigins, markListening, resetListeners } from './listeners';
537
545
  export type { Direction } from './locale-direction';
538
546
  export { directionOf, isRtl } from './locale-direction';
547
+ export type { LocalePathSplit } from './locale-path';
548
+ export { localeSegment, localizePath, splitLocalePath } from './locale-path';
539
549
  export { isMcpExposed, type McpExposureDeclaration } from './mcp-exposure';
540
550
  export type { MeasurementActorFactory } from './measurement-actor';
541
551
  export {
@@ -0,0 +1,65 @@
1
+ // A locale in a URL, as pure path arithmetic over an explicit locale list: the default locale is
2
+ // the UNPREFIXED path, every other one is `/<locale>/…`. Tier 0 for `locale-direction.ts`' reason:
3
+ // `@ultimat3/ui`'s `LocaleSwitcher` spells its links with it, and reaching the i18n barrel for one
4
+ // function puts the whole framework catalog into every browser chunk (issue #490). i18n wraps these
5
+ // with the app's configured locales (`localizedPath`, `splitLocalePrefix`), so apps call those.
6
+
7
+ /** The segment a locale is written as in a URL: lowercase, so `pt-BR` is `/pt-br/`. */
8
+ export function localeSegment(locale: string): string {
9
+ return locale.toLowerCase();
10
+ }
11
+
12
+ export interface LocalePathSplit {
13
+ /** The spelling the locale list carries. */
14
+ readonly locale: string;
15
+ /** The pathname with the segment removed — `/` for `/en` and `/en/`. */
16
+ readonly path: string;
17
+ /** The segment named the default locale: a duplicate URL, answered with a redirect. */
18
+ readonly isDefault: boolean;
19
+ }
20
+
21
+ /**
22
+ * `/en/precios` → `{ locale: 'en', path: '/precios' }`; `undefined` when the first segment names no
23
+ * listed locale. Exact lowercase match only: `/EN/x` is not a second spelling of `/en/x`, because
24
+ * two URLs for one document is the duplicate-content bug the default's redirect exists to prevent.
25
+ */
26
+ export function splitLocalePath(
27
+ pathname: string,
28
+ locales: readonly string[],
29
+ defaultLocale: string,
30
+ ): LocalePathSplit | undefined {
31
+ if (!pathname.startsWith('/')) return undefined;
32
+ const end = pathname.indexOf('/', 1);
33
+ const segment = end === -1 ? pathname.slice(1) : pathname.slice(1, end);
34
+ if (segment === '') return undefined;
35
+ const locale = locales.find((candidate) => localeSegment(candidate) === segment);
36
+ if (locale === undefined) return undefined;
37
+ const rest = end === -1 ? '' : pathname.slice(end);
38
+ return {
39
+ locale,
40
+ path: rest === '' ? '/' : rest,
41
+ isDefault: localeSegment(locale) === localeSegment(defaultLocale),
42
+ };
43
+ }
44
+
45
+ /**
46
+ * `path` as `locale` spells it: `/precios` + `en` → `/en/precios`, the default locale → `/precios`,
47
+ * and the root → `/en/` (the directory a static host serves `en/index.html` from without a
48
+ * redirect). A path already carrying a listed prefix is re-spelled, never doubled, so a language
49
+ * switcher can hand in the page it is on. A query or fragment is kept as written.
50
+ */
51
+ export function localizePath(
52
+ path: string,
53
+ locale: string,
54
+ locales: readonly string[],
55
+ defaultLocale: string,
56
+ ): string {
57
+ const cut = path.search(/[?#]/);
58
+ const pathname = cut === -1 ? path : path.slice(0, cut);
59
+ const suffix = cut === -1 ? '' : path.slice(cut);
60
+ const bare = splitLocalePath(pathname, locales, defaultLocale)?.path ?? pathname;
61
+ const rooted = bare.startsWith('/') ? bare : `/${bare}`;
62
+ if (localeSegment(locale) === localeSegment(defaultLocale)) return `${rooted}${suffix}`;
63
+ const prefix = `/${localeSegment(locale)}`;
64
+ return `${rooted === '/' ? `${prefix}/` : `${prefix}${rooted}`}${suffix}`;
65
+ }