@webjsdev/core 0.7.7 → 0.7.9

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/index.d.ts CHANGED
@@ -9,6 +9,50 @@
9
9
  */
10
10
 
11
11
  export * from './src/component.d.ts';
12
+ export type {
13
+ Metadata,
14
+ MetadataContext,
15
+ TitleMetadata,
16
+ ViewportMetadata,
17
+ RobotsMetadata,
18
+ AlternatesMetadata,
19
+ VerificationMetadata,
20
+ OpenGraphMetadata,
21
+ TwitterMetadata,
22
+ AppleWebAppMetadata,
23
+ IconsMetadata,
24
+ IconDescriptor,
25
+ AuthorMetadata,
26
+ PreloadDescriptor,
27
+ JsonLd,
28
+ } from './src/metadata.d.ts';
29
+
30
+ // Typed route props + the opt-in generated route union (#258). The
31
+ // `WebjsRoutes` / `RouteParamMap` interfaces are exported as VALUES-of-types
32
+ // (interfaces) so app code and the generated `.webjs/routes.d.ts` can augment
33
+ // them via `declare module '@webjsdev/core'`.
34
+ export type {
35
+ Route,
36
+ RouteParams,
37
+ PageProps,
38
+ LayoutProps,
39
+ RouteHandlerContext,
40
+ WebjsRoutes,
41
+ RouteParamMap,
42
+ } from './src/routes.d.ts';
43
+
44
+ // The package.json `webjs` config block (#259). Typed reference for the
45
+ // elide / headers / redirects / trailingSlash / csp / ingress-limit knobs;
46
+ // the companion JSON Schema (packages/server/webjs-config.schema.json) gives
47
+ // editors native validation of package.json itself.
48
+ export type {
49
+ WebjsConfig,
50
+ WebjsHeaderRule,
51
+ WebjsHeaderDirective,
52
+ WebjsRedirectRule,
53
+ WebjsTrailingSlash,
54
+ WebjsCspConfig,
55
+ } from './src/webjs-config.d.ts';
12
56
 
13
57
  export { html, isTemplate, MARKER } from './src/html.js';
14
58
  export { css, isCSS, adoptStyles, stylesToString } from './src/css.js';
@@ -17,12 +61,19 @@ export { renderToString, renderToStream } from './src/render-server.js';
17
61
  export { render } from './src/render-client.js';
18
62
  export { escapeText, escapeAttr } from './src/escape.js';
19
63
  export { notFound, redirect, isNotFound, isRedirect } from './src/nav.js';
20
- export { expose, getExposed } from './src/expose.js';
64
+ export { expose, getExposed, validateInput } from './src/expose.js';
21
65
  export { repeat, isRepeat } from './src/repeat.js';
22
66
  export { Suspense, isSuspense } from './src/suspense.js';
23
67
  export { connectWS } from './src/websocket-client.js';
24
68
  export { richFetch } from './src/rich-fetch.js';
25
- export { enableClientRouter, disableClientRouter, navigate } from './src/router-client.js';
69
+ export { enableClientRouter, disableClientRouter } from './src/router-client.js';
70
+
71
+ // `navigate` is typed against the generated `Route` union (#258) rather than
72
+ // the JSDoc `string`. Until an app runs `webjs types`, `Route` resolves to
73
+ // `string`, so this is non-breaking; once generated, a bogus in-app path is a
74
+ // tsserver error. The runtime is the same async function in router-client.js.
75
+ import type { Route } from './src/routes.d.ts';
76
+ export function navigate(url: Route, opts?: { replace?: boolean }): Promise<void>;
26
77
  export { unsafeHTML, isUnsafeHTML, live, isLive } from './src/directives.js';
27
78
  export { createContext, ContextProvider, ContextConsumer, ContextRequestEvent } from './src/context.js';
28
79
  export { Task, TaskStatus } from './src/task.js';
package/index.js CHANGED
@@ -15,7 +15,7 @@ export { render } from './src/render-client.js';
15
15
  export { escapeText, escapeAttr } from './src/escape.js';
16
16
  export { notFound, redirect, isNotFound, isRedirect } from './src/nav.js';
17
17
  export { cspNonce, setCspNonceProvider } from './src/csp-nonce.js';
18
- export { expose, getExposed } from './src/expose.js';
18
+ export { expose, getExposed, validateInput } from './src/expose.js';
19
19
  export { repeat, isRepeat } from './src/repeat.js';
20
20
  export { Suspense, isSuspense } from './src/suspense.js';
21
21
  export { connectWS } from './src/websocket-client.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/core",
3
- "version": "0.7.7",
3
+ "version": "0.7.9",
4
4
  "type": "module",
5
5
  "description": "webjs core runtime - html/css tags, WebComponent base, isomorphic renderers",
6
6
  "types": "./index.d.ts",
package/src/expose.js CHANGED
@@ -22,10 +22,22 @@
22
22
  * - For methods with a body (POST/PUT/PATCH/DELETE), the parsed JSON body
23
23
  * is merged on top of params/query. The function receives ONE argument:
24
24
  * the merged object.
25
- * - If `opts.validate` is provided, it runs BEFORE the function and can
26
- * transform / reject the input. Throw to fail (→ 400 response). Return
27
- * value replaces the input. Works cleanly with zod, valibot, or any
28
- * parser that throws: `expose('...', fn, { validate: Schema.parse })`.
25
+ * - If `opts.validate` is provided, it runs BEFORE the function on EVERY
26
+ * call path (the REST path here AND the internal RPC path), so a single
27
+ * validator declared once guards both. The framework calls `validate(input)`
28
+ * and interprets the return (see the validator contract below):
29
+ * - a `{ success: true, data? }` envelope passes (the action receives
30
+ * `data` when present, else the original input);
31
+ * - a `{ success: false, fieldErrors, message? }` envelope FAILS with a
32
+ * structured field-error result (422), without calling the action;
33
+ * - a THROW fails (→ 400 on REST, sanitized error on RPC), preserving the
34
+ * classic `Schema.parse`-style contract;
35
+ * - any OTHER returned plain value is treated as the transformed input
36
+ * (back-compat with the `validate: Schema.parse` transform style).
37
+ * The zod adapter is three lines and keeps the framework zod-free:
38
+ * `validate: (i) => { const r = Schema.safeParse(i);
39
+ * return r.success ? { success: true, data: r.data }
40
+ * : { success: false, fieldErrors: r.error.flatten().fieldErrors }; }`
29
41
  * - Return value becomes a JSON `Response`; throw or return a `Response`
30
42
  * directly for full control.
31
43
  *
@@ -84,6 +96,84 @@ function normaliseCors(c) {
84
96
  };
85
97
  }
86
98
 
99
+ /**
100
+ * Attach an input validator to a server action WITHOUT exposing it as a REST
101
+ * endpoint. The validator runs SERVER-SIDE before the action body on BOTH call
102
+ * paths: the internal RPC path (a client component importing the action) AND
103
+ * the `expose()` REST path (if the action is also exposed). This is the shared,
104
+ * declare-once validation surface (#245).
105
+ *
106
+ * It reuses the EXACT same `__webjsHttp` storage `expose()` writes, so the
107
+ * action index's `getExposed(fn)` already surfaces the validator with no new
108
+ * plumbing. The action stays a plain callable function (a direct unit-test
109
+ * import still works, just without the framework running validate).
110
+ *
111
+ * If the action is already `expose()`d, prefer passing `{ validate }` to
112
+ * `expose()` directly; calling both keeps the LAST validator attached.
113
+ *
114
+ * ```js
115
+ * // actions/posts.server.js
116
+ * 'use server';
117
+ * import { validateInput } from '@webjsdev/core';
118
+ *
119
+ * export const createPost = validateInput(
120
+ * async ({ title }) => { ... },
121
+ * (input) => {
122
+ * const errs = {};
123
+ * if (!input?.title?.trim()) errs.title = 'Title is required';
124
+ * return Object.keys(errs).length
125
+ * ? { success: false, fieldErrors: errs }
126
+ * : { success: true };
127
+ * },
128
+ * );
129
+ * ```
130
+ *
131
+ * The validator contract (the framework calls `validate(input)`):
132
+ * - returns `{ success: true, data? }` → valid; the action is called with
133
+ * `data` if present, else the original input;
134
+ * - returns `{ success: false, fieldErrors, message? }` (an object with a
135
+ * boolean `success` of `false`, or with a `fieldErrors`) → FAILED; the
136
+ * framework returns a structured `ActionResult`
137
+ * `{ success: false, fieldErrors, error: message?, status: 422 }` without
138
+ * calling the action body;
139
+ * - THROWS → mapped to a sanitized failure result (the classic
140
+ * `Schema.parse` contract);
141
+ * - returns ANY OTHER plain value → treated as the validated/coerced input
142
+ * and passed to the action (the `validate: Schema.parse` transform style).
143
+ *
144
+ * Disambiguation: a return is a result-envelope ONLY when it is an object with
145
+ * a boolean `success` property OR a `fieldErrors` property; otherwise it is a
146
+ * transformed input value. Caveat: if your validator returns the input
147
+ * VERBATIM (the bare-transform style) and that input can itself carry a
148
+ * `success` boolean or a `fieldErrors` key, it would be misread as an envelope.
149
+ * Return the explicit `{ success: true, data }` envelope in that case (the zod
150
+ * adapter and the recommended hand-rolled form already do, so they never
151
+ * collide).
152
+ *
153
+ * Server-side only; import inside `.server.{js,ts}` files. The bare
154
+ * `@webjsdev/core` specifier resolves to the browser entry, which excludes
155
+ * `validateInput` (like `expose`), so an import from client-bound code reads
156
+ * `undefined`. The validator itself lives in the `.server` file and never
157
+ * reaches the client.
158
+ *
159
+ * @template {Function} F
160
+ * @param {F} fn the async action implementation
161
+ * @param {(input: any) => any} validate the validator (a plain function or a
162
+ * zod-`safeParse` adapter)
163
+ * @returns {F} the same function, tagged with the validator
164
+ */
165
+ export function validateInput(fn, validate) {
166
+ if (typeof fn !== 'function') {
167
+ throw new Error('validateInput(): first argument must be the action function');
168
+ }
169
+ if (typeof validate !== 'function') {
170
+ throw new Error('validateInput(): second argument must be a validator function');
171
+ }
172
+ const existing = /** @type any */ (fn).__webjsHttp || {};
173
+ /** @type any */ (fn).__webjsHttp = { ...existing, validate };
174
+ return fn;
175
+ }
176
+
87
177
  /** @param {unknown} fn */
88
178
  export function getExposed(fn) {
89
179
  return fn && typeof fn === 'function' ? /** @type any */ (fn).__webjsHttp || null : null;
@@ -0,0 +1,279 @@
1
+ /**
2
+ * TypeScript overlay for the `metadata` / `generateMetadata` return shape.
3
+ *
4
+ * A page or layout exports `metadata` (static) or `generateMetadata(ctx)`
5
+ * (request-scoped). Both return a plain object that the SSR pipeline
6
+ * (packages/server/src/ssr.js) reads field by field and emits into the
7
+ * document `<head>`. This file types that object so authors get
8
+ * autocomplete and typo checking:
9
+ *
10
+ * import type { Metadata } from '@webjsdev/core';
11
+ * export const metadata: Metadata = { title: 'Home', description: '…' };
12
+ * export async function generateMetadata(ctx: MetadataContext): Promise<Metadata> { … }
13
+ *
14
+ * The shape mirrors what ssr.js actually consumes (and agent-docs/metadata.md
15
+ * documents), NOT Next.js's full superset. Every field is optional. Zero
16
+ * runtime cost: nothing in this file ships to the browser.
17
+ */
18
+
19
+ /** A URL or relative path. Relative values resolve against `metadataBase`. */
20
+ type MetadataUrl = string;
21
+
22
+ /** `title` accepts a plain string or the template object Next.js uses. */
23
+ export type TitleMetadata =
24
+ | string
25
+ | {
26
+ /** Wraps a deeper plain-string title via the `%s` placeholder. */
27
+ template?: string;
28
+ /** Title used when a deeper layer supplies none. */
29
+ default?: string;
30
+ /** Bypasses the inherited template entirely for this layer. */
31
+ absolute?: string;
32
+ };
33
+
34
+ /** An author entry: a bare name, or `{ name, url? }`. */
35
+ export type AuthorMetadata = string | { name?: string; url?: MetadataUrl };
36
+
37
+ /** `viewport` object form (Next.js 14+ split-export shape). */
38
+ export interface ViewportMetadata {
39
+ width?: string | number;
40
+ height?: string | number;
41
+ initialScale?: number;
42
+ minimumScale?: number;
43
+ maximumScale?: number;
44
+ /** `false` emits `user-scalable=no`, `true` emits `yes`. */
45
+ userScalable?: boolean;
46
+ viewportFit?: 'auto' | 'contain' | 'cover';
47
+ interactiveWidget?: 'resizes-visual' | 'resizes-content' | 'overlays-content';
48
+ /** May also carry `themeColor` / `colorScheme` on the split `viewport` export. */
49
+ themeColor?: string;
50
+ colorScheme?: string;
51
+ }
52
+
53
+ /** `robots` object form (a bare string is also accepted). */
54
+ export interface RobotsMetadata {
55
+ index?: boolean;
56
+ follow?: boolean;
57
+ noarchive?: boolean;
58
+ nosnippet?: boolean;
59
+ noimageindex?: boolean;
60
+ /** Emitted verbatim as `<meta name="googlebot">`. */
61
+ googleBot?: string;
62
+ }
63
+
64
+ /** `alternates`: canonical + i18n / media / type alternates. */
65
+ export interface AlternatesMetadata {
66
+ canonical?: MetadataUrl;
67
+ /** hreflang -> URL, e.g. `{ 'es-ES': '/es' }`. */
68
+ languages?: Record<string, MetadataUrl>;
69
+ /** media query -> URL, e.g. `{ '(max-width: 600px)': '/mobile' }`. */
70
+ media?: Record<string, MetadataUrl>;
71
+ /** MIME type -> URL, e.g. `{ 'application/rss+xml': '/rss.xml' }`. */
72
+ types?: Record<string, MetadataUrl>;
73
+ }
74
+
75
+ /** Site-verification tokens. Each value is a token or list of tokens. */
76
+ export interface VerificationMetadata {
77
+ google?: string | string[];
78
+ yandex?: string | string[];
79
+ yahoo?: string | string[];
80
+ /** IndieAuth / personal `<meta name="me">`. */
81
+ me?: string | string[];
82
+ /** Arbitrary `<meta name="…">` verification entries. */
83
+ other?: Record<string, string | string[]>;
84
+ }
85
+
86
+ /** A single icon descriptor. */
87
+ export type IconDescriptor =
88
+ | MetadataUrl
89
+ | { url: MetadataUrl; sizes?: string; type?: string };
90
+
91
+ /** `icons`: a bare URL, a list, or the bucketed object form. */
92
+ export type IconsMetadata =
93
+ | MetadataUrl
94
+ | IconDescriptor[]
95
+ | {
96
+ icon?: IconDescriptor | IconDescriptor[];
97
+ apple?: IconDescriptor | IconDescriptor[];
98
+ shortcut?: IconDescriptor | IconDescriptor[];
99
+ /** Catch-all: each entry carries its own `rel`. */
100
+ other?:
101
+ | { rel: string; url: MetadataUrl; sizes?: string; type?: string }
102
+ | Array<{ rel: string; url: MetadataUrl; sizes?: string; type?: string }>;
103
+ };
104
+
105
+ /**
106
+ * `openGraph`. Each key is emitted as `<meta property="og:<key>">`, so the
107
+ * indexer carries the documented keys plus an open-ended fallback for the
108
+ * `og:image:width` / `image:height` / `image:alt` style entries.
109
+ */
110
+ export interface OpenGraphMetadata {
111
+ type?: string;
112
+ title?: string;
113
+ description?: string;
114
+ url?: MetadataUrl;
115
+ image?: MetadataUrl;
116
+ site_name?: string;
117
+ [key: string]: string | undefined;
118
+ }
119
+
120
+ /** `twitter` card. Each key is emitted as `<meta name="twitter:<key>">`. */
121
+ export interface TwitterMetadata {
122
+ card?: 'summary' | 'summary_large_image' | 'app' | 'player';
123
+ title?: string;
124
+ description?: string;
125
+ image?: MetadataUrl;
126
+ site?: string;
127
+ creator?: string;
128
+ [key: string]: string | undefined;
129
+ }
130
+
131
+ /** A startup-image entry for `appleWebApp.startupImage`. */
132
+ export type AppleStartupImage =
133
+ | MetadataUrl
134
+ | { url: MetadataUrl; media?: string };
135
+
136
+ /** `appleWebApp`: `true` (capable) or the descriptor object. */
137
+ export type AppleWebAppMetadata =
138
+ | boolean
139
+ | {
140
+ capable?: boolean;
141
+ title?: string;
142
+ statusBarStyle?: 'default' | 'black' | 'black-translucent';
143
+ startupImage?: AppleStartupImage | AppleStartupImage[];
144
+ };
145
+
146
+ /**
147
+ * A single JSON-LD structured-data object (schema.org). The author owns the
148
+ * shape (Article, Product, BreadcrumbList, Organization, FAQPage, etc.), so
149
+ * this is intentionally permissive. The framework serializes it with
150
+ * `JSON.stringify` and HTML-safe-escapes the output into a
151
+ * `<script type="application/ld+json">` block.
152
+ */
153
+ export type JsonLd = Record<string, unknown>;
154
+
155
+ /** A `metadata.preload` link descriptor (emitted as `<link rel="preload">`). */
156
+ export interface PreloadDescriptor {
157
+ href: MetadataUrl;
158
+ as?: string;
159
+ type?: string;
160
+ crossorigin?: string;
161
+ media?: string;
162
+ [attr: string]: string | undefined;
163
+ }
164
+
165
+ /**
166
+ * The return shape of `metadata` / `generateMetadata`.
167
+ *
168
+ * Mirrors exactly what packages/server/src/ssr.js consumes. Every field is
169
+ * optional. Where the framework accepts a string OR an object (`title`,
170
+ * `viewport`, `robots`, `appleWebApp`, `icons`), the type is a union.
171
+ *
172
+ * @see agent-docs/metadata.md for the field-by-field emission reference.
173
+ */
174
+ export interface Metadata {
175
+ /** -> `<title>`. Plain string or `{ template, default, absolute }`. */
176
+ title?: TitleMetadata;
177
+ /** -> `<meta name="description">`. */
178
+ description?: string;
179
+ /** -> `<meta name="keywords">`. A list is comma-joined. */
180
+ keywords?: string | string[];
181
+ /** -> `<meta name="author">` (+ optional `<link rel="author">`). */
182
+ authors?: AuthorMetadata | AuthorMetadata[];
183
+ /** -> `<meta name="creator">`. */
184
+ creator?: string;
185
+ /** -> `<meta name="publisher">`. */
186
+ publisher?: string;
187
+ /** -> `<meta name="application-name">`. */
188
+ applicationName?: string;
189
+ /** -> `<meta name="generator">`. */
190
+ generator?: string;
191
+ /** -> `<meta name="referrer">`. */
192
+ referrer?: string;
193
+
194
+ /** Base for resolving every relative URL in this object. */
195
+ metadataBase?: string;
196
+
197
+ /** -> `<meta name="viewport">`. String form OR object form. */
198
+ viewport?: string | ViewportMetadata;
199
+ /** -> `<meta name="theme-color">`. */
200
+ themeColor?: string;
201
+ /** -> `<meta name="color-scheme">`. */
202
+ colorScheme?: string;
203
+
204
+ /** -> `<meta name="robots">`. A string OR the object form. */
205
+ robots?: string | RobotsMetadata;
206
+ /** Canonical + i18n / media / type alternates. */
207
+ alternates?: AlternatesMetadata;
208
+ /** Site-verification tokens. */
209
+ verification?: VerificationMetadata;
210
+
211
+ /** Icon links. Bare URL, list, or bucketed object. */
212
+ icons?: IconsMetadata;
213
+ /** -> `<link rel="manifest">`. */
214
+ manifest?: string;
215
+
216
+ /** Open Graph tags (`<meta property="og:*">`). */
217
+ openGraph?: OpenGraphMetadata;
218
+ /** Twitter card tags (`<meta name="twitter:*">`). */
219
+ twitter?: TwitterMetadata;
220
+
221
+ /** iOS web-app meta. `true` (capable) or the descriptor object. */
222
+ appleWebApp?: AppleWebAppMetadata;
223
+ /** -> `<meta name="format-detection">`. Each value is a boolean. */
224
+ formatDetection?: Record<string, boolean>;
225
+ /** -> `<meta name="apple-itunes-app">`. */
226
+ itunes?: { appId: string; appArgument?: string };
227
+
228
+ /** -> `<meta name="category">`. */
229
+ category?: string;
230
+ /** -> `<meta name="classification">`. */
231
+ classification?: string;
232
+ /** -> `<meta name="abstract">`. */
233
+ abstract?: string;
234
+ /** -> `<link rel="archives">`. */
235
+ archives?: string | string[];
236
+ /** -> `<link rel="assets">`. */
237
+ assets?: string | string[];
238
+ /** -> `<link rel="bookmark">`. */
239
+ bookmarks?: string | string[];
240
+
241
+ /**
242
+ * Response `Cache-Control` header (NOT a `<meta>` tag). Pages default to
243
+ * `no-store`; a public value (e.g. `public, max-age=60`) also enables
244
+ * conditional GET on the page.
245
+ */
246
+ cacheControl?: string;
247
+ /** `<link rel="preload">` hints (fonts, images, etc.). */
248
+ preload?: PreloadDescriptor[];
249
+
250
+ /**
251
+ * JSON-LD structured data (schema.org), emitted as one or more
252
+ * `<script type="application/ld+json">` blocks in `<head>`. A single
253
+ * object emits ONE script; an array emits one script PER element. The
254
+ * author owns the schema.org shape; the framework serializes with
255
+ * `JSON.stringify` and HTML-safe-escapes the output automatically (a
256
+ * value containing `</script>` can never break out of the tag). Works in
257
+ * `generateMetadata(ctx)` for per-request data (a per-post Article, etc.).
258
+ */
259
+ jsonLd?: JsonLd | JsonLd[];
260
+
261
+ /** Catch-all `<meta name="…">` entries. Value may be a list. */
262
+ other?: Record<string, string | number | Array<string | number>>;
263
+ }
264
+
265
+ /**
266
+ * The argument `generateMetadata(ctx)` receives. Mirrors the page context
267
+ * (the same `{ params, searchParams, url, actionData }` a page function gets).
268
+ */
269
+ export interface MetadataContext {
270
+ params: Record<string, string>;
271
+ searchParams?: Record<string, string | string[]>;
272
+ url: string;
273
+ /**
274
+ * Present only on the re-render after a failed page `action` submission.
275
+ * `undefined` on a normal GET render. Read it to vary the title on a
276
+ * validation-error re-render.
277
+ */
278
+ actionData?: unknown;
279
+ }
@@ -1473,6 +1473,23 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild) {
1473
1473
  blurOutgoingFocus();
1474
1474
  return;
1475
1475
  }
1476
+ // The response did not carry the requested frame (source null), or the
1477
+ // target frame is gone from the live DOM (target null). Falling through
1478
+ // would wholesale-replace the document, a silent full-page swap that
1479
+ // destroys the page (e.g. an auth redirect returning a login page without
1480
+ // the frame). Surface the contract violation with a cancelable event
1481
+ // instead. Default: warn and leave the frame unchanged. A listener that
1482
+ // calls preventDefault owns the outcome.
1483
+ const evt = new CustomEvent('webjs:frame-missing', {
1484
+ bubbles: true,
1485
+ cancelable: true,
1486
+ detail: { frameId, url: href || (typeof location !== 'undefined' ? location.href : null), document: doc },
1487
+ });
1488
+ (target || document).dispatchEvent(evt);
1489
+ if (!evt.defaultPrevented) {
1490
+ console.warn(`[webjs] frame "${frameId}" was not in the navigation response, leaving it unchanged. Handle "webjs:frame-missing" (preventDefault) to override.`);
1491
+ }
1492
+ return;
1476
1493
  }
1477
1494
 
1478
1495
  // 2. Auto-derived layout-marker swap.
@@ -0,0 +1,136 @@
1
+ /**
2
+ * TypeScript overlay for typed page / layout / route-handler props plus an
3
+ * opt-in, generated route union that types `navigate()` and catches bad
4
+ * hrefs at tsserver time (#258).
5
+ *
6
+ * webjs identity holds: NO build step, NO runtime cost, types-only, opt-in
7
+ * for JSDoc apps. The mechanism mirrors Next 15's `typedRoutes` but via
8
+ * interface declaration-merging instead of a bundler.
9
+ *
10
+ * import type { PageProps, LayoutProps } from '@webjsdev/core';
11
+ * export default function Post({ params }: PageProps<'/blog/[slug]'>) { … }
12
+ *
13
+ * Two augmentation targets, `WebjsRoutes` and `RouteParamMap`, are EMPTY by
14
+ * default, so an un-generated app sees `Route = string` and `params =
15
+ * Record<string, string>` (non-breaking). Running `webjs types` writes
16
+ * `.webjs/routes.d.ts`, which augments both with one key per route literal,
17
+ * narrowing `Route` to the concrete app routes and giving every page typed
18
+ * `params`.
19
+ *
20
+ * The shapes mirror what packages/server/src/ssr.js actually constructs for
21
+ * the page / layout context and what packages/server/src/api.js passes a
22
+ * route handler, NOT a guess. Zero runtime cost: nothing here ships to the
23
+ * browser.
24
+ */
25
+
26
+ import type { TemplateResult } from './html.js';
27
+
28
+ /**
29
+ * The augmentation target the generated `.webjs/routes.d.ts` fills with one
30
+ * key per route literal (e.g. `'/blog/[slug]': true`). Empty by default so an
31
+ * un-generated app keeps `Route = string`. App code (or the generator)
32
+ * augments it via `declare module '@webjsdev/core'`.
33
+ */
34
+ export interface WebjsRoutes {}
35
+
36
+ /**
37
+ * The augmentation target mapping a dynamic route key to its params object
38
+ * (`'/blog/[slug]': { slug: string }`). Emitted by the generator with the
39
+ * exact `string` / `string[]` / optional shapes known at generation time,
40
+ * which is far more robust than deep template-literal inference. Empty by
41
+ * default; a static route is never given an entry here, so its params fall
42
+ * through to `Record<string, string>` (the runtime default), not `{}`.
43
+ */
44
+ export interface RouteParamMap {}
45
+
46
+ /** True once either augmentation target carries at least one key. */
47
+ type _HasRoutes = keyof WebjsRoutes extends never ? false : true;
48
+
49
+ /**
50
+ * Turn a route key into the set of concrete path patterns it matches by
51
+ * replacing each dynamic segment with `${string}`. The optional catch-all
52
+ * `[[...x]]` is handled by the GENERATOR, which emits BOTH the with-segment
53
+ * and without-segment keys as separate `WebjsRoutes` entries (so `/docs` and
54
+ * `/docs/a/b` both satisfy a `/docs/[[...slug]]` route). Keeping that collapse
55
+ * in the generator (not in pure types) avoids the fragile `//`-collapse, so
56
+ * `RoutePattern` only ever needs the single-segment `[x]` and catch-all
57
+ * `[...x]` cases.
58
+ */
59
+ type RoutePattern<K extends string> =
60
+ K extends `${infer A}[...${string}]${infer B}`
61
+ ? `${A}${string}${RoutePattern<B>}`
62
+ : K extends `${infer A}[${string}]${infer B}`
63
+ ? `${A}${string}${RoutePattern<B>}`
64
+ : K;
65
+
66
+ /**
67
+ * The valid-href type. `string` when no routes are generated (so `navigate()`
68
+ * and JSDoc apps are never broken); once `WebjsRoutes` is augmented it is the
69
+ * union of every static route literal PLUS a template-literal pattern per
70
+ * dynamic route.
71
+ */
72
+ export type Route = _HasRoutes extends true
73
+ ? { [K in keyof WebjsRoutes]: RoutePattern<K & string> }[keyof WebjsRoutes]
74
+ : string;
75
+
76
+ /**
77
+ * The params object for a route key. A generated dynamic route resolves to
78
+ * its `RouteParamMap` entry (`{ slug: string }`, `{ rest: string[] }`,
79
+ * `{ slug?: string[] }`); anything else (a static route, or any route in an
80
+ * un-generated app) resolves to `Record<string, string>`, the runtime
81
+ * default the SSR pipeline produces.
82
+ */
83
+ export type RouteParams<R extends string> = R extends keyof RouteParamMap
84
+ ? RouteParamMap[R]
85
+ : Record<string, string>;
86
+
87
+ /**
88
+ * The argument a page default-export receives. Mirrors the `ctx` object
89
+ * packages/server/src/ssr.js builds: `{ params, searchParams, url,
90
+ * actionData }`.
91
+ *
92
+ * `searchParams` is typed `Record<string, string | string[]>` to match the
93
+ * established page-context convention, even though the runtime builds it with
94
+ * `Object.fromEntries(url.searchParams.entries())` (so a repeated key is
95
+ * last-wins `string`, never an array). The wider type keeps a future
96
+ * multi-value reader source-compatible.
97
+ */
98
+ export interface PageProps<R extends string = string> {
99
+ /**
100
+ * Path params. A generated dynamic route narrows this to its exact shape
101
+ * (`{ slug: string }`); a static or un-generated route is
102
+ * `Record<string, string>`.
103
+ */
104
+ params: R extends keyof RouteParamMap ? RouteParamMap[R] : Record<string, string>;
105
+ /** Query string, as an object. Repeated keys are last-wins at runtime. */
106
+ searchParams: Record<string, string | string[]>;
107
+ /** The full request URL string. */
108
+ url: string;
109
+ /**
110
+ * Present ONLY on the re-render after a failed page `action` submission
111
+ * (#244). `undefined` on a normal GET render. Read `actionData.fieldErrors`
112
+ * / `actionData.values` to surface validation errors and repopulate inputs.
113
+ */
114
+ actionData?: unknown;
115
+ }
116
+
117
+ /**
118
+ * The argument a layout default-export receives: every `PageProps` field plus
119
+ * the rendered `children`. Mirrors the `{ children, params, searchParams, url
120
+ * }` object packages/server/src/ssr.js passes a layout.
121
+ */
122
+ export interface LayoutProps<R extends string = string> extends PageProps<R> {
123
+ /** The nested page / inner-layout content this layout must embed. */
124
+ children: TemplateResult;
125
+ }
126
+
127
+ /**
128
+ * The 2nd argument a `route.{js,ts}` `GET` / `POST` / … handler receives.
129
+ * Mirrors what packages/server/src/api.js passes: `{ params }`. The params
130
+ * are a plain `Record<string, string>` at the handler layer (the route table
131
+ * decodes each captured segment to a string), with an optional `R` generic to
132
+ * narrow them against a generated route the same way `PageProps` does.
133
+ */
134
+ export interface RouteHandlerContext<R extends string = string> {
135
+ params: R extends keyof RouteParamMap ? RouteParamMap[R] : Record<string, string>;
136
+ }