@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 +2 -2
- package/src/config-site.ts +87 -0
- package/src/config.ts +8 -2
- package/src/index.ts +10 -0
- package/src/locale-path.ts +65 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/core",
|
|
3
|
-
"version": "22.
|
|
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.
|
|
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
|
+
}
|