@ultimat3/render 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/README.md CHANGED
@@ -453,6 +453,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
453
453
 
454
454
  | Class | Code | Declared in |
455
455
  |---|---|---|
456
+ | `AssetMissingError` | `X_ASSET_MISSING` | `src/errors.ts` |
456
457
  | `BudgetExceededError` | `X_BUDGET_EXCEEDED` | `src/errors.ts` |
457
458
  | `IslandInvalidError` | `X_ISLAND_INVALID` | `src/errors.ts` |
458
459
  | `IslandNotHydratedError` | `X_ISLAND_NOT_HYDRATED` | `src/errors.ts` |
@@ -477,6 +478,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
477
478
  | `defineRoute` | the `route` primitive |
478
479
  | `withStatus`, `routeStatusOf` | the status a loader answers, carried on its data; 200 when nothing asked |
479
480
  | `island`, `createIslandCollector` | one interactive component on a static page |
481
+ | `asset`, `setAssetResolver`, `assetPathProblem`, `AssetPath` | `asset('assets/x.avif')` → the content-hashed URL of a public site file; the table is the CLI's ([Static Assets](../../wiki/Static-Assets.md)) |
480
482
  | `MODE_SPECS`, `assertModeShape`, `assertModeInvariants` | the mode invariant table |
481
483
  | `registerRoute`, `describeRoutes`, `routeFor`, `routePathFromFile` | the route table |
482
484
  | `checkSurfaceBoundary`, `assertSurfaceBoundary`, `surfaceOf` | the hard boundary |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/render",
3
- "version": "22.2.1",
3
+ "version": "22.3.0",
4
4
  "description": "The route primitive and the five render modes: static, isr, ssr, stream, spa.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -36,11 +36,11 @@
36
36
  "test": "bun test"
37
37
  },
38
38
  "dependencies": {
39
- "@ultimat3/cache": "22.2.1",
40
- "@ultimat3/core": "22.2.1",
41
- "@ultimat3/http": "22.2.1",
42
- "@ultimat3/i18n": "22.2.1",
43
- "@ultimat3/seo": "22.2.1",
39
+ "@ultimat3/cache": "22.3.0",
40
+ "@ultimat3/core": "22.3.0",
41
+ "@ultimat3/http": "22.3.0",
42
+ "@ultimat3/i18n": "22.3.0",
43
+ "@ultimat3/seo": "22.3.0",
44
44
  "sass": "1.104.0"
45
45
  }
46
46
  }
package/src/asset.ts ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * `asset('assets/hero.avif')` — the one way a page names a public file of its own: the path under
3
+ * `apps/web/site/assets/` in, its content-hashed URL out. Typed so a path outside that tree or with
4
+ * an extension nothing serves is a compile error, and resolved while the page RENDERS so a file
5
+ * that is not there fails `x build --target static` with `X_ASSET_MISSING`, exactly as a bad
6
+ * `island({ src })` does. The table is the CLI's (`site-assets.ts`); this file holds only the seam.
7
+ */
8
+
9
+ import { AssetMissingError } from './errors';
10
+
11
+ /**
12
+ * What the asset surface serves, by extension. A list and not a pattern: each one has a content
13
+ * type the server answers with, and an extension outside it would be a file the export copies and
14
+ * the container serves as `application/octet-stream`.
15
+ */
16
+ export const ASSET_EXTENSIONS = [
17
+ 'avif',
18
+ 'webp',
19
+ 'png',
20
+ 'jpg',
21
+ 'jpeg',
22
+ 'gif',
23
+ 'svg',
24
+ 'ico',
25
+ 'woff2',
26
+ 'mp4',
27
+ 'webm',
28
+ 'vtt',
29
+ ] as const;
30
+
31
+ export type AssetExtension = (typeof ASSET_EXTENSIONS)[number];
32
+
33
+ /** Relative to the site surface, always under `assets/`: `assets/brand/logo.svg`. */
34
+ export type AssetPath = `assets/${string}.${AssetExtension}`;
35
+
36
+ /** Maps a declared path to the URL a browser fetches. Throws `X_ASSET_MISSING` on a miss. */
37
+ export type AssetResolver = (path: AssetPath) => string;
38
+
39
+ /** The directory every asset path starts with, and the URL prefix every hashed URL starts with. */
40
+ export const ASSET_DIR = 'assets';
41
+
42
+ const EXTENSIONS: ReadonlySet<string> = new Set(ASSET_EXTENSIONS);
43
+
44
+ /**
45
+ * Why `path` can never name a servable asset, or `undefined` when it can. Shared with the CLI's
46
+ * table and route, so the page, the build and the server refuse one set of paths — a `..` segment
47
+ * the type admits (`assets/../app.config.ts` is a `string`, and so an `AssetPath` suffix) is
48
+ * refused here, before any disk is asked.
49
+ */
50
+ export function assetPathProblem(path: string): string | undefined {
51
+ if (path.includes('\\')) return `"${path}" contains a backslash`;
52
+ const segments = path.split('/');
53
+ if (segments[0] !== ASSET_DIR || segments.length < 2) {
54
+ return `"${path}" is not under ${ASSET_DIR}/`;
55
+ }
56
+ if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) {
57
+ return `"${path}" has an empty, "." or ".." segment`;
58
+ }
59
+ const name = segments[segments.length - 1] ?? '';
60
+ const dot = name.lastIndexOf('.');
61
+ const extension = dot <= 0 ? '' : name.slice(dot + 1).toLowerCase();
62
+ if (!EXTENSIONS.has(extension)) {
63
+ return `"${path}" has an extension the asset surface does not serve (one of ${ASSET_EXTENSIONS.join(', ')})`;
64
+ }
65
+ return undefined;
66
+ }
67
+
68
+ /**
69
+ * Registered globally, like the island brand, so two copies of this module — the app's and the
70
+ * CLI's, resolved through different `node_modules` — read the table one boot installed.
71
+ */
72
+ const RESOLVER_SLOT: unique symbol = Symbol.for('ultimate.render.assetResolver') as never;
73
+
74
+ interface ResolverHolder {
75
+ [RESOLVER_SLOT]?: AssetResolver | undefined;
76
+ }
77
+
78
+ const holder = globalThis as unknown as ResolverHolder;
79
+
80
+ /**
81
+ * Installed by the process that renders: `loadApp` does it for `x dev`, the container and the
82
+ * static build alike. `undefined` uninstalls — a test that installed one hands the slot back.
83
+ */
84
+ export function setAssetResolver(resolver: AssetResolver | undefined): void {
85
+ holder[RESOLVER_SLOT] = resolver;
86
+ }
87
+
88
+ /**
89
+ * The hashed URL of one site asset, e.g. `/assets/hero.3f2a1b9c.avif`, served
90
+ * `public, max-age=31536000, immutable`. Call it in a page or a server component and hand the URL
91
+ * to an island as a prop: a browser has no asset table, and an island calling this throws.
92
+ */
93
+ export function asset(path: AssetPath): string {
94
+ const problem = assetPathProblem(path);
95
+ if (problem !== undefined) {
96
+ throw new AssetMissingError(
97
+ `asset(${JSON.stringify(path)}): ${problem}`,
98
+ `name a file under apps/web/site/${ASSET_DIR}/ with one of: ${ASSET_EXTENSIONS.join(', ')}`,
99
+ );
100
+ }
101
+ const resolver = holder[RESOLVER_SLOT];
102
+ if (resolver === undefined) {
103
+ throw new AssetMissingError(
104
+ `asset(${JSON.stringify(path)}) ran with no site asset table installed — in a browser (an island), or in a test that never loaded the app`,
105
+ 'call asset() in the page and pass the URL to the island as a prop; a unit test installs a table with setAssetResolver((path) => "/" + path)',
106
+ );
107
+ }
108
+ return resolver(path);
109
+ }
package/src/errors.ts CHANGED
@@ -25,6 +25,7 @@ export const RENDER_ERROR_CODES = [
25
25
  // stylesheet registry and `stylesFor`, so "the CSS a document on this surface carries" is a fact
26
26
  // about render's own output. `x verify` is only the surface that reports it.
27
27
  'X_STYLES_GLOBAL_MISSING',
28
+ 'X_ASSET_MISSING',
28
29
  ] as const;
29
30
 
30
31
  export type RenderErrorCode = (typeof RENDER_ERROR_CODES)[number];
@@ -47,6 +48,7 @@ export const RENDER_ERROR_TITLES: Readonly<Record<RenderErrorCode, string>> = {
47
48
  X_ISLAND_NOT_HYDRATED: 'a page renders an island that nothing would ever boot',
48
49
  X_STYLES_GLOBAL_MISSING:
49
50
  'a surface renders documents whose CSS defines no :root custom properties',
51
+ X_ASSET_MISSING: 'a page names a site asset the app does not have',
50
52
  };
51
53
 
52
54
  // Titles must be registered for `format()` to render the contract's first line. Every code above is
@@ -273,3 +275,19 @@ export class RouteStatusInvalidError extends UltimateError {
273
275
  });
274
276
  }
275
277
  }
278
+
279
+ /**
280
+ * `asset('assets/…')` named a file the app's site asset table does not hold. Raised while the
281
+ * page RENDERS, so the static build fails on it the way it fails on a bad island — a hashed URL
282
+ * that 404s in every browser is the one outcome a build-checked helper exists to prevent.
283
+ */
284
+ export class AssetMissingError extends UltimateError {
285
+ static readonly code = 'X_ASSET_MISSING' as const;
286
+ constructor(cause: string, fix: string) {
287
+ super({
288
+ code: AssetMissingError.code,
289
+ cause,
290
+ fix,
291
+ });
292
+ }
293
+ }
package/src/head-seo.ts CHANGED
@@ -15,6 +15,13 @@ const IDENTITY: Readonly<Record<SeoHeadTag['tag'], readonly string[]>> = {
15
15
  script: ['type'],
16
16
  };
17
17
 
18
+ /**
19
+ * Open Graph properties a document carries once PER VALUE. Keyed by property alone, the dedupe
20
+ * kept the last: an article with three tags published one, and a page in three locales named one
21
+ * alternate. Their content is part of their identity.
22
+ */
23
+ const REPEATABLE_PROPERTIES = new Set(['og:locale:alternate', 'article:tag']);
24
+
18
25
  /**
19
26
  * `<meta name="description">` → `meta:description`. Scripts also carry their position: a page
20
27
  * with three JSON-LD nodes emits three tags with identical attributes, and keying them alike
@@ -24,7 +31,14 @@ export function headTagKey(tag: SeoHeadTag, index: number): string {
24
31
  const identity = IDENTITY[tag.tag]
25
32
  .map((name) => tag.attrs[name])
26
33
  .filter((value): value is string => value !== undefined);
27
- return [tag.tag, ...identity, ...(tag.tag === 'script' ? [String(index)] : [])].join(':');
34
+ const property = tag.attrs['property'];
35
+ const repeated =
36
+ property !== undefined && REPEATABLE_PROPERTIES.has(property)
37
+ ? [tag.attrs['content'] ?? '']
38
+ : [];
39
+ return [tag.tag, ...identity, ...repeated, ...(tag.tag === 'script' ? [String(index)] : [])].join(
40
+ ':',
41
+ );
28
42
  }
29
43
 
30
44
  export function toHeadTag(tag: SeoHeadTag, index: number): HeadTag {
package/src/index.ts CHANGED
@@ -16,6 +16,9 @@ export type { HydrateStrategy, OfflineStrategy, RenderMode } from '@ultimat3/cor
16
16
  // never had); still named here because `@ultimat3/cli`'s budget reporter reads it beside the route
17
17
  // table it prints against.
18
18
  export { formatBytes, HYDRATE_STRATEGIES, OFFLINE_STRATEGIES, RENDER_MODES } from '@ultimat3/core';
19
+ /** `asset('assets/x.avif')` → the content-hashed URL the site asset surface serves it at. */
20
+ export type { AssetExtension, AssetPath, AssetResolver } from './asset';
21
+ export { ASSET_DIR, ASSET_EXTENSIONS, asset, assetPathProblem, setAssetResolver } from './asset';
19
22
  /** The `<meta name="ultimate-scope">` core's `pageClient()` reads, on private documents only. */
20
23
  export {
21
24
  CLIENT_PERSIST_META,
@@ -36,6 +39,7 @@ export {
36
39
  export { parseTtlMs } from './duration';
37
40
  export type { RenderErrorCode } from './errors';
38
41
  export {
42
+ AssetMissingError,
39
43
  BudgetExceededError,
40
44
  IslandInvalidError,
41
45
  IslandNotHydratedError,
@@ -127,6 +131,7 @@ export type {
127
131
  PrerenderFn,
128
132
  RenderResult,
129
133
  RevalidateConfig,
134
+ RouteAlternate,
130
135
  RouteBudget,
131
136
  RouteCache,
132
137
  RouteConfig,
@@ -4,6 +4,9 @@
4
4
  * `bun test` all load a component the same way and there is no separate "bundled" behaviour.
5
5
  */
6
6
 
7
+ // why: Bun ships no path API, and a relative app root has to be resolved against the working
8
+ // directory before it can be compared with the absolute paths the Bun plugin hands this loader.
9
+ import { resolve } from 'node:path';
7
10
  import { renderThrowable } from '@ultimat3/core';
8
11
  import { compileStylesheet, isGlobalStylesheet, stripCharset } from './css-modules';
9
12
  import { PrerenderFailedError } from './errors';
@@ -115,10 +118,12 @@ const surfaceOfSheet = (path: string): Surface | null =>
115
118
  * Every sheet already registered is classified again, because a sheet can load before the root is
116
119
  * named — and a classification frozen then would keep the answer the wrong root gave. The revision
117
120
  * moves only when an answer does, so naming the same root twice mints no new stylesheet URL.
118
- * `undefined` returns to the working directory.
121
+ * A relative root is resolved against the working directory. `undefined` returns to the working
122
+ * directory.
119
123
  */
120
124
  export function setStylesheetRoot(root: string | undefined): void {
121
- stylesheetRoot = root;
125
+ // Resolved: `loadApp('.')` is a legal call, and `.` is a prefix of no absolute path.
126
+ stylesheetRoot = root === undefined ? undefined : resolve(root);
122
127
  for (const [path, sheet] of stylesheets) {
123
128
  const surface = surfaceOfSheet(path);
124
129
  if (surface === sheet.surface) continue;
package/src/route-data.ts CHANGED
@@ -6,9 +6,21 @@
6
6
  */
7
7
 
8
8
  import { isUltimateError, renderThrowable } from '@ultimat3/core';
9
- import { useI18n } from '@ultimat3/i18n';
9
+ import {
10
+ currentLocale,
11
+ localizedPath,
12
+ routedLocales,
13
+ unlocalizedPath,
14
+ useI18n,
15
+ } from '@ultimat3/i18n';
10
16
  import { RouteLoadFailedError } from './errors';
11
- import type { RouteConfig, RouteContext, RouteData, RouteMetaContext } from './route';
17
+ import type {
18
+ RouteAlternate,
19
+ RouteConfig,
20
+ RouteContext,
21
+ RouteData,
22
+ RouteMetaContext,
23
+ } from './route';
12
24
 
13
25
  /**
14
26
  * The route's data for one render.
@@ -75,5 +87,21 @@ export function metaContextFor<TData = RouteData>(
75
87
  ctx: RouteContext,
76
88
  data: TData,
77
89
  ): RouteMetaContext<TData> {
78
- return { data, params: ctx.params, url: ctx.url, t: useI18n() };
90
+ // The UNPREFIXED path: `/en/precios` and `/precios` are one page, and every locale's spelling of
91
+ // it is derived from that one path rather than by rewriting whichever prefix the request wore.
92
+ const page = unlocalizedPath(pathnameOf(ctx.url));
93
+ const inLocale = (locale: string): string => localizedPath(page, locale);
94
+ const alternates: readonly RouteAlternate[] = routedLocales().map((locale) => ({
95
+ locale,
96
+ path: inLocale(locale),
97
+ }));
98
+ return {
99
+ data,
100
+ params: ctx.params,
101
+ url: ctx.url,
102
+ t: useI18n(),
103
+ locale: currentLocale(),
104
+ localizedPath: inLocale,
105
+ alternates,
106
+ };
79
107
  }
package/src/route.ts CHANGED
@@ -129,6 +129,24 @@ export interface RouteMetaContext<TData = RouteData> {
129
129
  readonly url: string;
130
130
  /** The request's own translator. Never a hardcoded string in a `<title>`. */
131
131
  readonly t: Translator;
132
+ /**
133
+ * The locale this document renders in: a `/<locale>/` prefix's, the default on an unprefixed
134
+ * `site/` page, the negotiated one elsewhere. What a localized `description` is chosen by.
135
+ */
136
+ readonly locale: string;
137
+ /** This page's own path spelled in `locale` — unprefixed for the default, `/en/…` otherwise. */
138
+ readonly localizedPath: (locale: string) => string;
139
+ /**
140
+ * This page in every routed locale, the default first — what the automatic hreflang cluster is
141
+ * built from. One entry for a single-locale app.
142
+ */
143
+ readonly alternates: readonly RouteAlternate[];
144
+ }
145
+
146
+ /** One page in one locale. */
147
+ export interface RouteAlternate {
148
+ readonly locale: string;
149
+ readonly path: string;
132
150
  }
133
151
 
134
152
  /** What an author writes. Sync or async, whichever the page's data needs. */