@webjsdev/core 0.8.0 → 0.9.1

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.
Files changed (85) hide show
  1. package/README.md +5 -0
  2. package/dist/webjs-core-browser.js +6 -5
  3. package/dist/webjs-core-browser.js.map +3 -3
  4. package/dist/webjs-core-testing.js +20 -19
  5. package/dist/webjs-core-testing.js.map +3 -3
  6. package/dist/webjs-core.js +7 -6
  7. package/dist/webjs-core.js.map +3 -3
  8. package/index-browser.js +14 -9
  9. package/index.d.ts +53 -10
  10. package/index.js +17 -9
  11. package/package.json +15 -2
  12. package/src/action-effects-client.d.ts +5 -0
  13. package/src/action-effects-client.js +84 -0
  14. package/src/action-seed-client.js +5 -5
  15. package/src/asset-url.d.ts +18 -0
  16. package/src/asset-url.js +12 -13
  17. package/src/async-directive.d.ts +18 -0
  18. package/src/async-directive.js +88 -0
  19. package/src/attribute-reader.js +197 -23
  20. package/src/binding-prefixes.js +0 -12
  21. package/src/component/lifecycle.js +760 -209
  22. package/src/component/reactive.js +2 -10
  23. package/src/component/server-element.js +23 -1
  24. package/src/component.d.ts +151 -41
  25. package/src/context.d.ts +94 -18
  26. package/src/context.js +341 -217
  27. package/src/converters.d.ts +7 -0
  28. package/src/converters.js +38 -0
  29. package/src/csp-nonce.d.ts +18 -0
  30. package/src/css.d.ts +32 -6
  31. package/src/css.js +178 -14
  32. package/src/directive.d.ts +58 -0
  33. package/src/directive.js +119 -0
  34. package/src/directives.d.ts +38 -1
  35. package/src/directives.js +158 -1
  36. package/src/form-action.d.ts +13 -0
  37. package/src/form-action.js +139 -4
  38. package/src/hot.js +23 -0
  39. package/src/html.d.ts +4 -0
  40. package/src/html.js +35 -3
  41. package/src/is-server.d.ts +2 -0
  42. package/src/is-server.js +18 -0
  43. package/src/jsx-guard.js +130 -0
  44. package/src/metadata.d.ts +22 -3
  45. package/src/nav.d.ts +4 -1
  46. package/src/nav.js +44 -11
  47. package/src/optimistic.js +2 -2
  48. package/src/refresh-header.js +52 -0
  49. package/src/registry.js +10 -1
  50. package/src/render-client/adopt-slots.js +8 -3
  51. package/src/render-client/parts.js +745 -31
  52. package/src/render-client/reconciler.js +56 -11
  53. package/src/render-client/template-compiler.js +139 -44
  54. package/src/render-client.d.ts +5 -1
  55. package/src/render-server/attrs.js +2 -2
  56. package/src/render-server/compile.js +34 -0
  57. package/src/render-server/errors.js +37 -0
  58. package/src/render-server/expand.js +61 -8
  59. package/src/render-server/served-attrs.js +1 -2
  60. package/src/render-server/walk.js +135 -3
  61. package/src/render-server.d.ts +3 -0
  62. package/src/render-server.js +2 -0
  63. package/src/repeat.d.ts +7 -2
  64. package/src/repeat.js +20 -6
  65. package/src/router-client/dom-differ.js +17 -0
  66. package/src/router-client/fetch-apply.js +86 -5
  67. package/src/router-client/navigator.js +45 -9
  68. package/src/router-client/prefetch.js +3 -4
  69. package/src/router-client/root-attrs.js +43 -0
  70. package/src/router-client/stream.js +7 -0
  71. package/src/router-client/swap.js +4 -1
  72. package/src/routes.d.ts +75 -18
  73. package/src/serialize.d.ts +2 -0
  74. package/src/serialize.js +35 -4
  75. package/src/signal.d.ts +7 -0
  76. package/src/signal.js +26 -6
  77. package/src/slot/polyfills.js +3 -3
  78. package/src/slot.js +4 -8
  79. package/src/static-html.d.ts +22 -0
  80. package/src/static-html.js +144 -0
  81. package/src/task.d.ts +63 -20
  82. package/src/task.js +326 -293
  83. package/src/testing.d.ts +42 -0
  84. package/src/webjs-config.d.ts +1 -8
  85. package/src/websocket-client.d.ts +24 -0
package/src/metadata.d.ts CHANGED
@@ -11,8 +11,8 @@
11
11
  * export const metadata: Metadata = { title: 'Home', description: '…' };
12
12
  * export async function generateMetadata(ctx: MetadataContext): Promise<Metadata> { … }
13
13
  *
14
- * The shape mirrors what ssr.js actually consumes (and the skill's
15
- * references/routing-and-pages.md documents), NOT Next.js's full superset.
14
+ * The shape mirrors what ssr.js actually consumes (and
15
+ * https://webjs.dev/docs/metadata-routes documents), NOT Next.js's full superset.
16
16
  * Every field is optional. Zero
17
17
  * runtime cost: nothing in this file ships to the browser.
18
18
  */
@@ -209,7 +209,7 @@ export interface PreloadDescriptor {
209
209
  * optional. Where the framework accepts a string OR an object (`title`,
210
210
  * `viewport`, `robots`, `appleWebApp`, `icons`), the type is a union.
211
211
  *
212
- * @see the skill's references/routing-and-pages.md for the field-by-field emission reference.
212
+ * @see https://webjs.dev/docs/metadata-routes for the field-by-field emission reference.
213
213
  */
214
214
  export interface Metadata {
215
215
  /** -> `<title>`. Plain string or `{ template, default, absolute }`. */
@@ -337,3 +337,22 @@ export interface MetadataContext {
337
337
  */
338
338
  actionData?: unknown;
339
339
  }
340
+
341
+ /**
342
+ * A page's or layout's `generateMetadata` export, Next.js's: called per
343
+ * request with the page context and a promise of the metadata the outer
344
+ * layouts resolved, it returns this segment's {@link Metadata}. Export a static
345
+ * `metadata` object instead when nothing depends on the request.
346
+ *
347
+ * @example
348
+ * ```ts
349
+ * // app/posts/[slug]/page.ts
350
+ * import type { Metadata, MetadataContext } from '@webjsdev/core';
351
+ * export async function generateMetadata({ params }: MetadataContext, parent: Promise<Metadata>): Promise<Metadata> {
352
+ * const post = await getPost(params.slug);
353
+ * const images = (await parent).openGraph?.images ?? [];
354
+ * return { title: post?.title ?? 'Not found', openGraph: { images } };
355
+ * }
356
+ * ```
357
+ */
358
+ export type GenerateMetadata = (ctx: MetadataContext, parent: Promise<Metadata>) => Metadata | Promise<Metadata>;
package/src/nav.d.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  export function notFound(): never;
2
- export function redirect(url: string, status?: number | { status?: number }): never;
2
+ /** How the client records a redirect that reaches the browser (Next's RedirectType). */
3
+ export type RedirectType = 'push' | 'replace';
4
+ export function redirect(url: string, status?: number | RedirectType | { status?: number; type?: RedirectType }): never;
5
+ export function permanentRedirect(url: string, type?: RedirectType): never;
3
6
  export function forbidden(): never;
4
7
  export function unauthorized(): never;
5
8
  export function isNotFound(e: unknown): boolean;
package/src/nav.js CHANGED
@@ -59,31 +59,64 @@ export function unauthorized() {
59
59
  * each kind of redirect gets the conventional code:
60
60
  * - thrown during a GET page/layout render (a gating redirect like an auth
61
61
  * bounce to `/login`): 302 Found, the conventional GET-to-GET code.
62
- * - thrown from a server action (a POST): 307 Temporary Redirect, which is
63
- * method-preserving so the action's intent survives. (The PRG success
64
- * path uses 303 separately.)
62
+ * - thrown from a `route.ts` handler: 307 Temporary Redirect.
63
+ * - thrown from a server action bound to a form: always 303 See Other, so
64
+ * the browser follows with a GET and the action runs once (Next parity).
65
+ * - thrown from a server action called over RPC: the client router
66
+ * navigates to the target.
65
67
  * Pass an explicit status to override: `redirect('/x', 308)` (permanent) or the
66
- * options form `redirect('/x', { status: 301 })`. An explicit status wins at
67
- * every catching site.
68
+ * options form `redirect('/x', { status: 301 })`. An explicit status wins
69
+ * everywhere except a form-bound action, which always answers 303.
70
+ *
71
+ * The second argument may instead be `'push'` or `'replace'` (Next's
72
+ * `RedirectType`): how the client records the navigation when the redirect
73
+ * reaches the browser from an action called over RPC. `'push'` is the
74
+ * default there, as in a Next.js server action.
68
75
  *
69
76
  * @param {string} url
70
- * @param {number | { status?: number }} [status] explicit status (number) or
71
- * `{ status }`; omit to let the catching site pick (302 for a GET gate, 307
72
- * for an action).
77
+ * @param {number | RedirectType | { status?: number, type?: RedirectType }} [status]
78
+ * explicit status (number), a `RedirectType`, or `{ status, type }`; omit to
79
+ * let the catching site pick.
73
80
  * @returns {never}
74
81
  */
75
82
  export function redirect(url, status) {
76
- const code = typeof status === 'object' && status !== null ? status.status : status;
83
+ /** @type {number | undefined} */
84
+ let code;
85
+ /** @type {RedirectType | undefined} */
86
+ let type;
87
+ if (status === 'push' || status === 'replace') type = status;
88
+ else if (typeof status === 'object' && status !== null) {
89
+ code = status.status;
90
+ if (status.type === 'push' || status.type === 'replace') type = status.type;
91
+ } else if (typeof status === 'number') code = status;
77
92
  const err = new Error(`webjs: redirect(${url})`);
78
93
  /** @type any */ (err).__webjs = REDIRECT;
79
94
  /** @type any */ (err).url = url;
80
95
  // Left undefined when the caller did not specify one, so the catching site
81
- // can apply its convention (302 GET gate vs 307 action). A caller-supplied
82
- // code is stored verbatim and overrides that convention.
96
+ // can apply its convention (302 GET gate, 307 route handler, 303 form action).
97
+ // A caller-supplied code is stored verbatim and overrides that convention,
98
+ // except at a form-bound action, which always answers 303.
83
99
  /** @type any */ (err).status = typeof code === 'number' ? code : undefined;
100
+ /** @type any */ (err).redirectType = type;
84
101
  throw err;
85
102
  }
86
103
 
104
+ /**
105
+ * Throw a PERMANENT redirect sentinel, Next.js's `permanentRedirect()`: a 308
106
+ * Permanent Redirect everywhere except a form-bound action, which answers 303. Use it
107
+ * when a URL has moved for good (a renamed slug); browsers and search engines
108
+ * remember a 308.
109
+ *
110
+ * @param {string} url
111
+ * @param {RedirectType} [type] how an RPC caller's client records the navigation.
112
+ * @returns {never}
113
+ */
114
+ export function permanentRedirect(url, type) {
115
+ return redirect(url, { status: 308, type });
116
+ }
117
+
118
+ /** @typedef {'push' | 'replace'} RedirectType */
119
+
87
120
  /** @param {unknown} e */
88
121
  export function isNotFound(e) {
89
122
  return !!e && /** @type any */ (e).__webjs === NOT_FOUND;
package/src/optimistic.js CHANGED
@@ -108,8 +108,8 @@ async function runImperativeOptimistic(signal, value, action) {
108
108
  * Imperative form (signal-based rollback), for a one-shot toggle:
109
109
  * await optimistic(signal, value, () => likePost(postId));
110
110
  *
111
- * Client-only: it calls `host.requestUpdate()` (client work), so a component
112
- * importing it is never elided as display-only.
111
+ * Client-only: it calls `host.requestUpdate()` (client work), so it belongs in
112
+ * a component, never in a server-only one.
113
113
  *
114
114
  * @template State
115
115
  * @template Action
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The `x-webjs-refresh` header a server action's response carries when it
3
+ * called `revalidatePath()`. A leaf module (no imports), so the router's
4
+ * fetch path and the action stub's effects can both read it without an
5
+ * import cycle.
6
+ *
7
+ * @module refresh-header
8
+ */
9
+
10
+ /**
11
+ * Parse an `x-webjs-refresh` header: comma-separated, percent-encoded paths,
12
+ * each optionally suffixed `;layout` (Next's `revalidatePath(path, 'layout')`).
13
+ * @param {string | null | undefined} value
14
+ * @returns {{ path: string, layout: boolean }[]}
15
+ */
16
+ export function parseRefreshHeader(value) {
17
+ if (!value) return [];
18
+ /** @type {{ path: string, layout: boolean }[]} */
19
+ const out = [];
20
+ for (const part of String(value).split(',')) {
21
+ const raw = part.trim();
22
+ if (!raw) continue;
23
+ const layout = raw.endsWith(';layout');
24
+ let path = layout ? raw.slice(0, -';layout'.length) : raw;
25
+ try { path = decodeURIComponent(path); } catch { /* keep as sent */ }
26
+ if (path.startsWith('/')) out.push({ path, layout });
27
+ }
28
+ return out;
29
+ }
30
+
31
+ /**
32
+ * Whether `pathname` is covered by one refreshed entry. A `[param]` segment
33
+ * matches any one segment, a `[...param]` segment one or more and a
34
+ * `[[...param]]` segment zero or more of the rest
35
+ * (Next lets `revalidatePath` name a route pattern); a `layout` entry also
36
+ * covers every path below it.
37
+ * @param {{ path: string, layout: boolean }} entry
38
+ * @param {string} pathname
39
+ */
40
+ export function refreshCovers(entry, pathname) {
41
+ const want = entry.path.split('/').filter(Boolean);
42
+ const have = pathname.split('/').filter(Boolean);
43
+ for (let i = 0; i < want.length; i++) {
44
+ const w = want[i];
45
+ if (/^\[\[\.\.\./.test(w)) return true;
46
+ if (/^\[\.\.\./.test(w)) return have.length > i;
47
+ if (i >= have.length) return false;
48
+ if (/^\[[^\]]+\]$/.test(w)) continue;
49
+ if (w !== have[i]) return false;
50
+ }
51
+ return entry.layout || have.length === want.length;
52
+ }
package/src/registry.js CHANGED
@@ -17,6 +17,8 @@
17
17
  * @typedef {{ cls: typeof import('./component.js').WebComponent, moduleUrl: string | null, lazy: boolean, tag: string }} RegistryEntry
18
18
  */
19
19
 
20
+ import { hotReplacer } from './hot.js';
21
+
20
22
  /* ------------------------------------------------------------------
21
23
  * Shared registry on globalThis.
22
24
  *
@@ -106,7 +108,11 @@ function registerInternal(tag, cls) {
106
108
  if (isBrowser) {
107
109
  const native = customElements.define.bind(customElements);
108
110
  /** @type {any} */ (customElements).define = function (tag, cls, options) {
109
- if (!customElements.get(tag)) native(tag, cls, options);
111
+ const prev = customElements.get(tag);
112
+ if (!prev) native(tag, cls, options);
113
+ // A re-define of a known tag under the dev hot client (#1689) patches the
114
+ // registered class in place, so the registry keeps the class the browser uses.
115
+ else if (prev !== cls) { const replace = hotReplacer(); if (replace) cls = replace(tag, prev, cls); }
110
116
  registerInternal(tag, cls);
111
117
  };
112
118
  }
@@ -152,6 +158,9 @@ if (!isBrowser) {
152
158
  * @param {typeof import('./component.js').WebComponent} cls
153
159
  */
154
160
  export function register(tag, cls) {
161
+ // Lit's `finalize()`, so the class's metadata is ready before the first
162
+ // instance (a class not built on WebComponent has none).
163
+ if (typeof (/** @type {any} */ (cls)).finalize === 'function') /** @type {any} */ (cls).finalize();
155
164
  if (isBrowser) {
156
165
  /** @type any */ (customElements).define(tag, cls);
157
166
  } else {
@@ -140,9 +140,14 @@ export function retargetParts(plan, bound) {
140
140
  if (!p.el) continue;
141
141
  const live = plan.map.get(p.el);
142
142
  if (!live) continue;
143
- if (p.kind === 'event') {
144
- p.el.removeEventListener(p.name, p.dispatcher);
145
- live.addEventListener(p.name, p.dispatcher);
143
+ // An event part registers its dispatcher in applyPart, with the handler as
144
+ // the options bag (capture / once / passive). This runs before the first
145
+ // apply, so usually nothing is registered yet; move a registration only
146
+ // when one exists, with the SAME options, or a capture listener would be
147
+ // registered twice and a `once` listener would lose its `once`.
148
+ if (p.kind === 'event' && p.handler) {
149
+ p.el.removeEventListener(p.name, p.dispatcher, p.handler);
150
+ live.addEventListener(p.name, p.dispatcher, p.handler);
146
151
  }
147
152
  p.el = live;
148
153
  if (p.kind === 'attr' || p.kind === 'attr-mixed' || p.kind === 'bool') {