@webjsdev/core 0.7.6 → 0.7.8

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-browser.js CHANGED
@@ -48,8 +48,16 @@ export { WebjsFrame } from './src/webjs-frame.js';
48
48
  // Signals (TC39 Stage-1 shape), also available via '@webjsdev/core/signals'
49
49
  export { signal, computed, effect, batch, isSignal, Signal } from './src/signal.js';
50
50
 
51
- // Directives, also available via '@webjsdev/core/directives'
52
- export { unsafeHTML, isUnsafeHTML, live, isLive } from './src/directives.js';
51
+ // Directives, also available via '@webjsdev/core/directives'. The full
52
+ // lit-html-parity set is re-exported here so the dist browser bundle (which
53
+ // the `@webjsdev/core/directives` subpath collapses onto in dist mode) carries
54
+ // every directive, matching what src/directives.js exports in dev.
55
+ export {
56
+ unsafeHTML, isUnsafeHTML, live, isLive, keyed, isKeyed, guard, isGuard,
57
+ templateContent, isTemplateContent, ref, isRef, createRef, cache, isCache,
58
+ until, isUntil, asyncAppend, isAsyncAppend, asyncReplace, isAsyncReplace,
59
+ watch, isWatch,
60
+ } from './src/directives.js';
53
61
 
54
62
  // Context Protocol, also available via '@webjsdev/core/context'
55
63
  export { createContext, ContextProvider, ContextConsumer, ContextRequestEvent } from './src/context.js';
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';
@@ -22,7 +66,14 @@ 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
@@ -30,8 +30,15 @@ export { WebjsFrame } from './src/webjs-frame.js';
30
30
  // Signals (TC39 Stage-1 shape), also available via '@webjsdev/core/signals'
31
31
  export { signal, computed, effect, batch, isSignal, Signal } from './src/signal.js';
32
32
 
33
- // Directives, also available via '@webjsdev/core/directives'
34
- export { unsafeHTML, isUnsafeHTML, live, isLive } from './src/directives.js';
33
+ // Directives, also available via '@webjsdev/core/directives'. The full
34
+ // lit-html-parity set is re-exported so the bare specifier exposes the same
35
+ // directive surface in Node as the browser bundle does.
36
+ export {
37
+ unsafeHTML, isUnsafeHTML, live, isLive, keyed, isKeyed, guard, isGuard,
38
+ templateContent, isTemplateContent, ref, isRef, createRef, cache, isCache,
39
+ until, isUntil, asyncAppend, isAsyncAppend, asyncReplace, isAsyncReplace,
40
+ watch, isWatch,
41
+ } from './src/directives.js';
35
42
 
36
43
  // Context Protocol, also available via '@webjsdev/core/context'
37
44
  export { createContext, ContextProvider, ContextConsumer, ContextRequestEvent } from './src/context.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/core",
3
- "version": "0.7.6",
3
+ "version": "0.7.8",
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/component.js CHANGED
@@ -260,6 +260,96 @@ class ServerElement {
260
260
  return [...this.__ssrAttrs.keys()];
261
261
  }
262
262
 
263
+ /**
264
+ * Minimal `Element.closest()` for SSR. The SSR walker threads the chain
265
+ * of enclosing custom-element instances into each instance
266
+ * (`__ssrAncestors`); this walks self-then-ancestors and returns the
267
+ * nearest whose tag matches. Only bare tag-name selectors are supported
268
+ * server-side (`closest('ui-tabs')`), which is what compound components
269
+ * need to read parent state for a correct first paint; anything more
270
+ * specific (class, attribute, or descendant selectors) returns null,
271
+ * matching the pre-shim behaviour. The browser runs the real `closest()`
272
+ * on hydration.
273
+ * @param {string} selector
274
+ * @returns {any}
275
+ */
276
+ closest(selector) {
277
+ const sel = String(selector).trim().toLowerCase();
278
+ // Tag-name selectors only at SSR; bail (null) on anything else.
279
+ if (!/^[a-z][a-z0-9-]*$/.test(sel)) return null;
280
+ if (this.__ssrTag === sel) return this;
281
+ const chain = this.__ssrAncestors;
282
+ if (!Array.isArray(chain)) return null;
283
+ for (let i = chain.length - 1; i >= 0; i--) {
284
+ if (chain[i] && chain[i].__ssrTag === sel) return chain[i];
285
+ }
286
+ return null;
287
+ }
288
+
289
+ /**
290
+ * `HTMLElement.dataset`: a live view over the element's `data-*`
291
+ * attributes, backed by the SSR attribute Map. Reading / writing
292
+ * `el.dataset.fooBar` maps to the `data-foo-bar` attribute (camelCase
293
+ * to kebab-case), so a `render()` that sets `this.dataset.state = 'on'`
294
+ * surfaces `data-state="on"` in the SSR'd host tag instead of crashing
295
+ * on an undefined `dataset`.
296
+ * @returns {Record<string, string>}
297
+ */
298
+ get dataset() {
299
+ if (this.__dataset) return this.__dataset;
300
+ const el = this;
301
+ const toAttr = (p) => 'data-' + String(p).replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
302
+ this.__dataset = new Proxy(/** @type {Record<string,string>} */ ({}), {
303
+ get(_t, prop) {
304
+ if (typeof prop !== 'string') return undefined;
305
+ const v = el.getAttribute(toAttr(prop));
306
+ return v === null ? undefined : v;
307
+ },
308
+ set(_t, prop, value) {
309
+ if (typeof prop === 'string') el.setAttribute(toAttr(prop), value);
310
+ return true;
311
+ },
312
+ has(_t, prop) {
313
+ return typeof prop === 'string' && el.hasAttribute(toAttr(prop));
314
+ },
315
+ deleteProperty(_t, prop) {
316
+ if (typeof prop === 'string') el.removeAttribute(toAttr(prop));
317
+ return true;
318
+ },
319
+ ownKeys() {
320
+ return el.getAttributeNames()
321
+ .filter((n) => n.startsWith('data-'))
322
+ .map((n) => n.slice(5).replace(/-([a-z])/g, (_m, c) => c.toUpperCase()));
323
+ },
324
+ getOwnPropertyDescriptor(_t, prop) {
325
+ if (typeof prop === 'string' && el.hasAttribute(toAttr(prop))) {
326
+ return { enumerable: true, configurable: true, value: el.getAttribute(toAttr(prop)) };
327
+ }
328
+ return undefined;
329
+ },
330
+ });
331
+ return this.__dataset;
332
+ }
333
+
334
+ // IDL properties that reflect to a content attribute. A render() that
335
+ // mutates these on the host (a light-DOM compound-component pattern, e.g.
336
+ // `this.className = ...`, `this.hidden = !active`) then surfaces the
337
+ // matching attribute in the SSR'd host tag, matching the browser.
338
+ get className() { return this.getAttribute('class') ?? ''; }
339
+ set className(v) { this.setAttribute('class', v); }
340
+ get hidden() { return this.hasAttribute('hidden'); }
341
+ set hidden(v) { this.toggleAttribute('hidden', !!v); }
342
+ get id() { return this.getAttribute('id') ?? ''; }
343
+ set id(v) { this.setAttribute('id', v); }
344
+ get title() { return this.getAttribute('title') ?? ''; }
345
+ set title(v) { this.setAttribute('title', v); }
346
+ get slot() { return this.getAttribute('slot') ?? ''; }
347
+ set slot(v) { this.setAttribute('slot', v); }
348
+ get role() { return this.getAttribute('role'); }
349
+ set role(v) { v == null ? this.removeAttribute('role') : this.setAttribute('role', v); }
350
+ get tabIndex() { const v = this.getAttribute('tabindex'); return v === null ? -1 : (Number.parseInt(v, 10) || 0); }
351
+ set tabIndex(v) { this.setAttribute('tabindex', String(v)); }
352
+
263
353
  // No server event loop: listeners never fire at SSR. The no-op keeps a
264
354
  // constructor that wires delegated listeners (a common lit pattern) from
265
355
  // crashing; the browser re-runs the constructor on hydration where the
@@ -282,6 +372,30 @@ class ServerElement {
282
372
  }
283
373
  }
284
374
 
375
+ // ARIAMixin IDL reflections (`el.ariaPressed = 'true'` writes aria-pressed).
376
+ // A render() that sets ARIA state via the IDL properties then surfaces the
377
+ // matching aria-* attribute in the SSR'd host tag, matching the browser. The
378
+ // IDL name maps to the content attribute by lowercasing the part after
379
+ // `aria` and prefixing `aria-` (ariaPressed -> aria-pressed).
380
+ const ARIA_IDL_PROPS = [
381
+ 'ariaAtomic', 'ariaAutoComplete', 'ariaBusy', 'ariaChecked', 'ariaColCount',
382
+ 'ariaColIndex', 'ariaColSpan', 'ariaCurrent', 'ariaDescription', 'ariaDisabled',
383
+ 'ariaExpanded', 'ariaHasPopup', 'ariaHidden', 'ariaInvalid', 'ariaKeyShortcuts',
384
+ 'ariaLabel', 'ariaLevel', 'ariaLive', 'ariaModal', 'ariaMultiLine',
385
+ 'ariaMultiSelectable', 'ariaOrientation', 'ariaPlaceholder', 'ariaPosInSet',
386
+ 'ariaPressed', 'ariaReadOnly', 'ariaRequired', 'ariaRoleDescription',
387
+ 'ariaRowCount', 'ariaRowIndex', 'ariaRowSpan', 'ariaSelected', 'ariaSetSize',
388
+ 'ariaSort', 'ariaValueMax', 'ariaValueMin', 'ariaValueNow', 'ariaValueText',
389
+ ];
390
+ for (const idl of ARIA_IDL_PROPS) {
391
+ const attr = 'aria-' + idl.slice(4).toLowerCase();
392
+ Object.defineProperty(ServerElement.prototype, idl, {
393
+ configurable: true,
394
+ get() { return this.getAttribute(attr); },
395
+ set(v) { v == null ? this.removeAttribute(attr) : this.setAttribute(attr, String(v)); },
396
+ });
397
+ }
398
+
285
399
  // Base class choice: real HTMLElement on the browser, the shim on the server.
286
400
  const Base = isBrowser ? HTMLElement : /** @type {any} */ (ServerElement);
287
401
 
package/src/directives.js CHANGED
@@ -12,11 +12,17 @@
12
12
  * } from '@webjsdev/core/directives';
13
13
  * ```
14
14
  *
15
- * `repeat()` lives in `./repeat.js` (re-exported from the package root).
15
+ * `repeat()` is implemented in `./repeat.js`; it is re-exported here so the
16
+ * `@webjsdev/core/directives` subpath exposes the whole documented directive
17
+ * table in src/dev mode too (the dist browser bundle already carries it).
16
18
  *
17
19
  * @module directives
18
20
  */
19
21
 
22
+ // Re-export so the `/directives` subpath surface matches the documented table
23
+ // in both src and dist mode. The implementation stays in repeat.js.
24
+ export { repeat, isRepeat } from './repeat.js';
25
+
20
26
  /* ================================================================
21
27
  * unsafeHTML
22
28
  * ================================================================ */
@@ -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
+ }
@@ -362,7 +362,7 @@ function browserMemberHint(e) {
362
362
  * @param {SuspenseCtx} [ctx]
363
363
  * @returns {Promise<string>}
364
364
  */
365
- async function injectDSD(html, ctx) {
365
+ async function injectDSD(html, ctx, ancestors = []) {
366
366
  const tags = allTags();
367
367
  if (!tags.length) return html;
368
368
  // Sort longest tag name first so the regex alternation tries the most
@@ -391,6 +391,12 @@ async function injectDSD(html, ctx) {
391
391
  try {
392
392
  const isShadow = /** @type any */ (Cls).shadow === true;
393
393
  const instance = new /** @type any */ (Cls)();
394
+ // Thread the ancestor chain (the enclosing custom-element instances)
395
+ // so the server element shim's closest() can resolve a parent at SSR.
396
+ // Set before performServerUpdate so a willUpdate() that reads a parent
397
+ // via closest() sees the chain. Each child recursion below extends it.
398
+ instance.__ssrTag = tag;
399
+ instance.__ssrAncestors = ancestors;
394
400
  const attrMap = parseAttrs(attrs);
395
401
  // Decode `data-webjs-prop-*` attributes first (rich-typed values
396
402
  // emitted for `.prop=${val}` bindings in the parent template),
@@ -416,13 +422,16 @@ async function injectDSD(html, ctx) {
416
422
  // SSR. WebComponent instances expose performServerUpdate; bare
417
423
  // Base-extending kit components (no lifecycle) do not, so it is guarded.
418
424
  if (typeof instance.performServerUpdate === 'function') instance.performServerUpdate();
419
- // Surface attributes the component set before render (reflected
420
- // reflect:true props, or an explicit this.setAttribute in the
421
- // constructor / willUpdate) that were not already in the source tag.
422
- // Appending keeps the original tag byte-identical when nothing changed.
423
- opening = appendReflectedAttrs(opening, instance, presentAttrNames);
424
425
  let tpl = instance.render ? instance.render() : '';
425
426
  if (tpl && typeof tpl.then === 'function') tpl = await tpl;
427
+ // Surface attributes the component set up to and including render()
428
+ // that were not already in the source tag: reflected reflect:true
429
+ // props, an explicit this.setAttribute in the constructor / willUpdate,
430
+ // or a host-attribute mutation inside render() itself (a light-DOM
431
+ // compound-component pattern, e.g. this.dataset.state / this.className /
432
+ // this.hidden on the host). Reading after render() captures all three.
433
+ // Appending keeps the original tag byte-identical when nothing changed.
434
+ opening = appendReflectedAttrs(opening, instance, presentAttrNames);
426
435
  // Render the template to HTML. injectDSD recurses on the result so
427
436
  // nested custom elements (e.g. <theme-toggle> inside <blog-shell>)
428
437
  // get their own DSD pass.
@@ -432,7 +441,7 @@ async function injectDSD(html, ctx) {
432
441
  // Shadow DOM: native <slot> stays as-is in the DSD template. The
433
442
  // browser handles projection from the host's light-DOM children
434
443
  // into the shadow tree natively. No framework substitution here.
435
- const innerProcessed = await injectDSD(rawInner, ctx);
444
+ const innerProcessed = await injectDSD(rawInner, ctx, [...ancestors, instance]);
436
445
  const rawStyles = /** @type any */ (Cls).styles;
437
446
  const styleList = Array.isArray(rawStyles) ? rawStyles : rawStyles && isCSS(rawStyles) ? [rawStyles] : [];
438
447
  const styleStr = stylesToString(styleList);
@@ -495,7 +504,7 @@ async function injectDSD(html, ctx) {
495
504
  }
496
505
  const partitioned = partitionAuthoredBySlot(authoredInner);
497
506
  const innerWithSlots = substituteSlotsInRender(rawInner, partitioned);
498
- const innerProcessed = await injectDSD(innerWithSlots, ctx);
507
+ const innerProcessed = await injectDSD(innerWithSlots, ctx, [...ancestors, instance]);
499
508
  edits.push({
500
509
  start: m.index,
501
510
  end: closeEnd,
@@ -523,6 +532,11 @@ async function injectDSD(html, ctx) {
523
532
  // independently against the original html, but those inner elements
524
533
  // are processed by the recursive injectDSD call on innerWithSlots.
525
534
  // Keeping both edits would double-process them and corrupt the output.
535
+ // A consequence: a nested instance's render() runs once per chain depth
536
+ // (the discarded top-level pass sees an empty ancestor chain, so its
537
+ // closest() reads null; the kept recursive pass has the real chain). The
538
+ // kept pass is the only output, and closest() is a read, so render() must
539
+ // stay pure at SSR (the standard SSR contract), not branch on side effects.
526
540
  edits.sort((a, b) => a.start - b.start);
527
541
  /** @type {{start:number, end:number, text:string}[]} */
528
542
  const filtered = [];
@@ -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.