@ultimat3/render 25.2.0 → 26.0.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/CLAUDE.md +1 -1
- package/README.md +5 -5
- package/package.json +6 -6
- package/src/index.ts +1 -0
- package/src/modes.ts +2 -9
- package/src/registry.ts +37 -31
- package/src/surfaces.ts +6 -0
package/CLAUDE.md
CHANGED
|
@@ -61,7 +61,7 @@ axiom 6). Never `cli` (upward).
|
|
|
61
61
|
| A held island | `hold` on the collector (the host decides — the CLI holds realtime islands on a page-boot document); `island-hold.ts` owns the attribute, the inline `visibility:hidden`, the cap `ISLAND_HOLD_MS` and the runtime part. Emitted right after the prelude; booted at once, revealed TOGETHER on both mount arms, at the cap, or by CSS (`ISLAND_HOLD_REVEAL`, keyframes in `@ultimat3/ui`'s `global.scss`) — never hidden for good. `catchUp`'s `on` lets go after a mount it did not flush. `island-hold.test.ts`, `island-hold-replay.test.ts`. |
|
|
62
62
|
| `idle`'s deadline | `IDLE_HYDRATE_TIMEOUT_MS`, interpolated INTO the runtime string and exported for `x shot`'s settle. |
|
|
63
63
|
| Route truth | `registry.ts`. Never a second route list and never a second matcher — `@ultimat3/http`'s trie (`stages.ts`) serves requests. `routeFor` is an exact-path `Map` lookup. |
|
|
64
|
-
| Route filename | `page.tsx` under `site/`/`app
|
|
64
|
+
| Route filename | `page.tsx` under `site/`/`app/` — `ROUTE_FILENAME`, keyed by `RouteSurface`. Anything else is `X_ROUTE_FILE_INVALID`, any file under `api/` or `shared/` included. |
|
|
65
65
|
| Mounted routes | `registerMountedRoutes(mount, routes)` (`mounted-routes.ts`) — routes a PACKAGE mounts with no surface file (the admin's screens). In `describePages()` with `mount: { by, permissions }`, never in `routeEntries()`: described, not rendered here. Re-declaring a `key` replaces its set; a path a file route also claims is `X_ROUTE_DUPLICATE` in both orders. `RouteDescriptor.mount` is absent on a file route. |
|
|
66
66
|
| Registry input | descriptors only. `registerRoute` refuses a raw declaration with `X_ROUTE_UNNORMALIZED`; it fills in only the island budget. |
|
|
67
67
|
| Descriptors | `describePages()` stays JSON-safe, sorted by path, deterministic. |
|
package/README.md
CHANGED
|
@@ -137,8 +137,7 @@ registered under is `X_ROUTE_POST_INVALID` at boot, as is `post` on a `static` p
|
|
|
137
137
|
| gated (`policy`) | its `cache` is never `public`/`immutable` — one actor's document in a shared cache | `X_ROUTE_MODE_INVALID` |
|
|
138
138
|
| `stream` | at least one `<Suspense>` boundary | `X_ROUTE_MODE_INVALID` |
|
|
139
139
|
|
|
140
|
-
Plus surface rules: `site/` allows `static | isr | ssr`, `app/` allows `stream | ssr`,
|
|
141
|
-
`api/` renders nothing, and a `site/` route that opts into hydration without a `budget.js`
|
|
140
|
+
Plus surface rules: `site/` allows `static | isr | ssr`, `app/` allows `stream | ssr`, and a `site/` route that opts into hydration without a `budget.js`
|
|
142
141
|
is a build error.
|
|
143
142
|
|
|
144
143
|
## The route table is the single source of route truth
|
|
@@ -154,11 +153,12 @@ sorted, JSON-safe descriptor list. Every downstream generator reads that one tab
|
|
|
154
153
|
| `site/blog/[slug]/page.tsx` | `/blog/:slug` |
|
|
155
154
|
| `site/docs/[...path]/page.tsx` | `/docs/*path` — `compilePattern` matches the bare `/docs` too, the path the static build writes for an empty rest |
|
|
156
155
|
| `app/dashboard/page.tsx` | `/dashboard` |
|
|
157
|
-
| `api/posts/route.ts` | `/api/posts` |
|
|
158
156
|
|
|
159
157
|
The URL is the **directory** path under the surface; the filename names the kind of file, never a
|
|
160
|
-
URL segment. One spelling
|
|
161
|
-
`
|
|
158
|
+
URL segment. One spelling — `page.tsx` under `site/` and `app/` — and `registerRoute` refuses anything else
|
|
159
|
+
with `X_ROUTE_FILE_INVALID`, a file under `api/` included: `api/` holds the actions and queries
|
|
160
|
+
`defineApi()` collects, and a wire format an action cannot speak is a plain HTTP route in the
|
|
161
|
+
`routes` runtime override. `index.tsx` is not
|
|
162
162
|
a page. Two spellings would make "is this file a route?" undecidable for the module scan, the
|
|
163
163
|
boundary walk, `sw.js` and the author reading the folder; one spelling also co-locates
|
|
164
164
|
`page.tsx` + `page.module.scss` + `page.test.ts`, and gives `[slug]/` its own stylesheet.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/render",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "26.0.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",
|
|
@@ -39,11 +39,11 @@
|
|
|
39
39
|
"test": "bun test"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@ultimat3/cache": "
|
|
43
|
-
"@ultimat3/core": "
|
|
44
|
-
"@ultimat3/http": "
|
|
45
|
-
"@ultimat3/i18n": "
|
|
46
|
-
"@ultimat3/seo": "
|
|
42
|
+
"@ultimat3/cache": "26.0.0",
|
|
43
|
+
"@ultimat3/core": "26.0.0",
|
|
44
|
+
"@ultimat3/http": "26.0.0",
|
|
45
|
+
"@ultimat3/i18n": "26.0.0",
|
|
46
|
+
"@ultimat3/seo": "26.0.0",
|
|
47
47
|
"sass": "1.105.1"
|
|
48
48
|
}
|
|
49
49
|
}
|
package/src/index.ts
CHANGED
package/src/modes.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { HYDRATE_STRATEGIES, RENDER_MODES, renderCauseValue } from '@ultimat3/co
|
|
|
10
10
|
import { parseTtlMs } from './duration';
|
|
11
11
|
import { RouteModeInvalidError } from './errors';
|
|
12
12
|
import type { RouteConfig } from './route';
|
|
13
|
-
import type { Surface } from './surfaces';
|
|
13
|
+
import type { RouteSurface, Surface } from './surfaces';
|
|
14
14
|
import { SURFACE_SPECS, surfaceAllows } from './surfaces';
|
|
15
15
|
|
|
16
16
|
/**
|
|
@@ -177,20 +177,13 @@ function hasRevalidateTrigger(config: RouteShape): boolean {
|
|
|
177
177
|
export interface ModeCheckContext {
|
|
178
178
|
readonly file: string;
|
|
179
179
|
readonly path: string;
|
|
180
|
-
readonly surface:
|
|
180
|
+
readonly surface: RouteSurface;
|
|
181
181
|
/** Counted from the route module's JSX by the build. `stream` needs at least one. */
|
|
182
182
|
readonly suspenseBoundaries: number;
|
|
183
183
|
}
|
|
184
184
|
|
|
185
185
|
/** Checks that need the surrounding module and surface. Called by `registerRoute`. */
|
|
186
186
|
export function assertModeInvariants(config: RouteShape, ctx: ModeCheckContext): void {
|
|
187
|
-
if (ctx.surface === 'api') {
|
|
188
|
-
throw new RouteModeInvalidError(
|
|
189
|
-
`${ctx.file} is in api/, which renders nothing, but declares render: '${config.render}'`,
|
|
190
|
-
`move ${ctx.file} into site/ or app/, or replace defineRoute with an action`,
|
|
191
|
-
);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
187
|
if (!surfaceAllows(ctx.surface, config.render)) {
|
|
195
188
|
const allowed = SURFACE_SPECS[ctx.surface].allowedModes.join(' | ');
|
|
196
189
|
throw new RouteModeInvalidError(
|
package/src/registry.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The route table — the single source of route truth. File path → URL conventions for
|
|
3
|
-
* `site
|
|
3
|
+
* `site/` and `app/`, plus `describePages()`, the serializable projection that
|
|
4
4
|
* `x.manifest.json`, the `/_x` routes panel, the sitemap and `sw.js` are all generated
|
|
5
5
|
* from. Nothing downstream may keep its own list of routes.
|
|
6
6
|
*/
|
|
@@ -31,40 +31,31 @@ import { isRouteConfig, routeTagKeys } from './route';
|
|
|
31
31
|
import type { RouteComponent } from './route-component';
|
|
32
32
|
import type { CompiledPattern } from './route-pattern';
|
|
33
33
|
import { compilePattern } from './route-pattern';
|
|
34
|
-
import type { Surface } from './surfaces';
|
|
34
|
+
import type { RouteSurface, Surface } from './surfaces';
|
|
35
35
|
import { locateSurface } from './surfaces';
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
|
-
* The one filename a route may carry, per surface.
|
|
39
|
-
*
|
|
40
|
-
* and stating that in the key type makes the other three MANDATORY. `Partial<Record<Surface, …>>`
|
|
41
|
-
* said the same thing about `shared` and let any of the three go missing, which only three
|
|
42
|
-
* registration tests would have caught. A dropped `api` row is not a crash: `assertRouteFilename`
|
|
43
|
-
* reads `undefined` as "this file is under shared/", so every `api/` route author would have been
|
|
44
|
-
* told their file is a leaf of helpers.
|
|
38
|
+
* The one filename a route may carry, per route surface. Keyed by `RouteSurface`, so both rows are
|
|
39
|
+
* MANDATORY: a dropped row would read as `undefined` in `assertRouteFilename`.
|
|
45
40
|
*/
|
|
46
|
-
export const ROUTE_FILENAME = Object.freeze<Record<
|
|
41
|
+
export const ROUTE_FILENAME = Object.freeze<Record<RouteSurface, string>>({
|
|
47
42
|
site: 'page.tsx',
|
|
48
43
|
app: 'page.tsx',
|
|
49
|
-
api: 'route.ts',
|
|
50
44
|
});
|
|
51
45
|
|
|
52
46
|
/** Stems that already meant "this directory", so the repair is a rename in place, not a new folder. */
|
|
53
47
|
const DIRECTORY_STEMS = new Set(['index', 'page', 'route']);
|
|
54
48
|
|
|
55
|
-
const API_PREFIX = '/api';
|
|
56
|
-
|
|
57
49
|
export interface RouteEntry<TData = RouteData> {
|
|
58
50
|
readonly file: string;
|
|
59
51
|
readonly path: string;
|
|
60
|
-
readonly surface:
|
|
52
|
+
readonly surface: RouteSurface;
|
|
61
53
|
readonly config: RouteConfig<TData>;
|
|
62
54
|
readonly suspenseBoundaries: number;
|
|
63
55
|
readonly islands: readonly string[];
|
|
64
56
|
readonly pattern: CompiledPattern;
|
|
65
57
|
/**
|
|
66
|
-
* The module's page component. Absent for
|
|
67
|
-
* a `spa` shell is the mode that legitimately has no server-rendered body.
|
|
58
|
+
* The module's page component. Absent for a module that exports none.
|
|
68
59
|
*/
|
|
69
60
|
readonly component?: RouteComponent;
|
|
70
61
|
}
|
|
@@ -119,9 +110,8 @@ export type { MountedRouteInput, RouteMount, RouteMountInput } from './mounted-r
|
|
|
119
110
|
* | `site/blog/[slug]/page.tsx` | `/blog/:slug` |
|
|
120
111
|
* | `site/docs/[...path]/page.tsx` | `/docs/*path` |
|
|
121
112
|
* | `app/dashboard/page.tsx` | `/dashboard` |
|
|
122
|
-
* | `api/posts/route.ts` | `/api/posts` |
|
|
123
113
|
*/
|
|
124
|
-
export function routePathFromFile(file: string): { surface:
|
|
114
|
+
export function routePathFromFile(file: string): { surface: RouteSurface; path: string } {
|
|
125
115
|
const normalized = file.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
126
116
|
// One reader of the surface segment, and it answers WHERE as well as WHICH. Slicing at
|
|
127
117
|
// `indexOf('app/')` instead matched inside `myapp/`, so `apps/myapp/app/page.tsx` resolved to
|
|
@@ -130,10 +120,10 @@ export function routePathFromFile(file: string): { surface: Surface; path: strin
|
|
|
130
120
|
if (located === null) {
|
|
131
121
|
throw new SurfaceBoundaryError(
|
|
132
122
|
`${file} is not inside a surface directory, so it has no URL and no bundle graph`,
|
|
133
|
-
`move ${file} under site
|
|
123
|
+
`move ${file} under site/ or app/, named page.tsx`,
|
|
134
124
|
);
|
|
135
125
|
}
|
|
136
|
-
const surface = located.surface;
|
|
126
|
+
const surface = routeSurfaceOf(normalized, located.surface);
|
|
137
127
|
|
|
138
128
|
const rawSegments = located.rest.split('/').filter((s) => s.length > 0);
|
|
139
129
|
assertRouteFilename(normalized, surface, rawSegments[rawSegments.length - 1]);
|
|
@@ -143,8 +133,7 @@ export function routePathFromFile(file: string): { surface: Surface; path: strin
|
|
|
143
133
|
.filter((s) => !(s.startsWith('(') && s.endsWith(')')))
|
|
144
134
|
.map(toUrlSegment);
|
|
145
135
|
|
|
146
|
-
const
|
|
147
|
-
const path = `${base}/${urlSegments.join('/')}`.replace(/\/+$/, '') || '/';
|
|
136
|
+
const path = `/${urlSegments.join('/')}`.replace(/\/+$/, '') || '/';
|
|
148
137
|
return { surface, path };
|
|
149
138
|
}
|
|
150
139
|
|
|
@@ -156,20 +145,37 @@ export function routePathFromFile(file: string): { surface: Surface; path: strin
|
|
|
156
145
|
const shellQuote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`;
|
|
157
146
|
|
|
158
147
|
/**
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
148
|
+
* Only `site/` and `app/` hold route files. `shared/` is a leaf of helpers with no URL; `api/` is
|
|
149
|
+
* the action and query projections `defineApi()` collects — a wire format an action cannot speak
|
|
150
|
+
* is a plain HTTP route in the `routes` runtime override, never a file under `api/`.
|
|
162
151
|
*/
|
|
163
|
-
function
|
|
164
|
-
|
|
165
|
-
// this is a comparison rather than an `undefined` check on the lookup.
|
|
166
|
-
const expected = surface === 'shared' ? undefined : ROUTE_FILENAME[surface];
|
|
167
|
-
if (expected === undefined) {
|
|
152
|
+
function routeSurfaceOf(file: string, surface: Surface): RouteSurface {
|
|
153
|
+
if (surface === 'shared') {
|
|
168
154
|
throw new RouteFileInvalidError(
|
|
169
155
|
`${file} is under shared/, which is a leaf of helpers with no URL — a route cannot live there`,
|
|
170
|
-
`move ${file} under site
|
|
156
|
+
`move ${file} under site/ or app/, named page.tsx`,
|
|
171
157
|
);
|
|
172
158
|
}
|
|
159
|
+
if (surface === 'api') {
|
|
160
|
+
throw new RouteFileInvalidError(
|
|
161
|
+
`${file} is under api/, which holds no route file: api/ is the actions and queries defineApi() collects`,
|
|
162
|
+
`JSON in and out: replace ${file} with an action (x g action <name>) collected by defineApi() in api/index.ts. Another wire format (a webhook, an OAuth endpoint): delete ${file} and add a plain HTTP route to the routes array exported as runtime from apps/<app>/runtime.ts`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return surface;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Enforced rather than documented (axiom 3): a convention that is not a build error is not a
|
|
170
|
+
* convention. The fix is the move that makes the file a route, spelled out — the directory the
|
|
171
|
+
* author already meant, plus the one filename that surface accepts.
|
|
172
|
+
*/
|
|
173
|
+
function assertRouteFilename(
|
|
174
|
+
file: string,
|
|
175
|
+
surface: RouteSurface,
|
|
176
|
+
basename: string | undefined,
|
|
177
|
+
): void {
|
|
178
|
+
const expected = ROUTE_FILENAME[surface];
|
|
173
179
|
if (basename === expected) return;
|
|
174
180
|
|
|
175
181
|
// The directory the author meant is the file's own path minus its extension: `site/pricing.tsx`
|
package/src/surfaces.ts
CHANGED
|
@@ -11,6 +11,12 @@ import { SurfaceBoundaryError } from './errors';
|
|
|
11
11
|
|
|
12
12
|
export type Surface = 'site' | 'app' | 'api' | 'shared';
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* The two surfaces a route file lives on. `api/` is a surface for the import boundary only: it holds
|
|
16
|
+
* the action and query projections `defineApi()` collects, and no route file.
|
|
17
|
+
*/
|
|
18
|
+
export type RouteSurface = Exclude<Surface, 'api' | 'shared'>;
|
|
19
|
+
|
|
14
20
|
export const SURFACES = ['site', 'app', 'api', 'shared'] as const;
|
|
15
21
|
|
|
16
22
|
export interface SurfaceSpec {
|