netjes 0.0.1 → 0.0.3

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 (154) hide show
  1. package/README.md +11 -11
  2. package/cli/agents.mjs +25 -2
  3. package/cli/commands.mjs +56 -6
  4. package/cli/help.mjs +7 -2
  5. package/cli/info.mjs +7 -2
  6. package/cli/scaffold.mjs +31 -4
  7. package/dist/actions.d.ts +7 -2
  8. package/dist/actions.d.ts.map +1 -1
  9. package/dist/actions.js +9 -3
  10. package/dist/actions.js.map +1 -1
  11. package/dist/client-entry.d.ts +1 -0
  12. package/dist/client-entry.d.ts.map +1 -1
  13. package/dist/client-entry.js +43 -2
  14. package/dist/client-entry.js.map +1 -1
  15. package/dist/deferred.d.ts +11 -0
  16. package/dist/deferred.d.ts.map +1 -1
  17. package/dist/deferred.js +15 -1
  18. package/dist/deferred.js.map +1 -1
  19. package/dist/document.d.ts +4 -3
  20. package/dist/document.d.ts.map +1 -1
  21. package/dist/document.js +15 -5
  22. package/dist/document.js.map +1 -1
  23. package/dist/errors.d.ts +7 -0
  24. package/dist/errors.d.ts.map +1 -0
  25. package/dist/errors.js +11 -0
  26. package/dist/errors.js.map +1 -0
  27. package/dist/form.d.ts +5 -5
  28. package/dist/form.d.ts.map +1 -1
  29. package/dist/form.js +5 -2
  30. package/dist/form.js.map +1 -1
  31. package/dist/href.d.ts +24 -3
  32. package/dist/href.d.ts.map +1 -1
  33. package/dist/href.js +117 -7
  34. package/dist/href.js.map +1 -1
  35. package/dist/image.d.ts +3 -3
  36. package/dist/image.d.ts.map +1 -1
  37. package/dist/image.js.map +1 -1
  38. package/dist/index.d.ts +2 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +2 -1
  41. package/dist/index.js.map +1 -1
  42. package/dist/link.d.ts +15 -7
  43. package/dist/link.d.ts.map +1 -1
  44. package/dist/link.js +38 -7
  45. package/dist/link.js.map +1 -1
  46. package/dist/metadata.d.ts +8 -2
  47. package/dist/metadata.d.ts.map +1 -1
  48. package/dist/metadata.js +14 -5
  49. package/dist/metadata.js.map +1 -1
  50. package/dist/modules.d.ts +15 -1
  51. package/dist/modules.d.ts.map +1 -1
  52. package/dist/modules.js.map +1 -1
  53. package/dist/navigation-focus.d.ts +38 -0
  54. package/dist/navigation-focus.d.ts.map +1 -0
  55. package/dist/navigation-focus.js +71 -0
  56. package/dist/navigation-focus.js.map +1 -0
  57. package/dist/payload.d.ts +2 -0
  58. package/dist/payload.d.ts.map +1 -1
  59. package/dist/payload.js +2 -0
  60. package/dist/payload.js.map +1 -1
  61. package/dist/query.d.ts +5 -2
  62. package/dist/query.d.ts.map +1 -1
  63. package/dist/query.js +11 -8
  64. package/dist/query.js.map +1 -1
  65. package/dist/render-document.d.ts +60 -0
  66. package/dist/render-document.d.ts.map +1 -0
  67. package/dist/render-document.js +330 -0
  68. package/dist/render-document.js.map +1 -0
  69. package/dist/route-announcer.d.ts +18 -0
  70. package/dist/route-announcer.d.ts.map +1 -0
  71. package/dist/route-announcer.js +58 -0
  72. package/dist/route-announcer.js.map +1 -0
  73. package/dist/route-tree.d.ts +6 -2
  74. package/dist/route-tree.d.ts.map +1 -1
  75. package/dist/route-tree.js +17 -7
  76. package/dist/route-tree.js.map +1 -1
  77. package/dist/router.d.ts +28 -5
  78. package/dist/router.d.ts.map +1 -1
  79. package/dist/router.js +71 -26
  80. package/dist/router.js.map +1 -1
  81. package/dist/script.d.ts +28 -5
  82. package/dist/script.d.ts.map +1 -1
  83. package/dist/script.js +192 -21
  84. package/dist/script.js.map +1 -1
  85. package/dist/server-render.d.ts.map +1 -1
  86. package/dist/server-render.js +4 -1
  87. package/dist/server-render.js.map +1 -1
  88. package/dist/testing-vitest.d.ts +8 -0
  89. package/dist/testing-vitest.d.ts.map +1 -0
  90. package/dist/testing-vitest.js +26 -0
  91. package/dist/testing-vitest.js.map +1 -0
  92. package/dist/testing.d.ts +11 -1
  93. package/dist/testing.d.ts.map +1 -1
  94. package/dist/testing.js +17 -3
  95. package/dist/testing.js.map +1 -1
  96. package/dist/zod-jitless.d.ts +2 -0
  97. package/dist/zod-jitless.d.ts.map +1 -0
  98. package/dist/zod-jitless.js +4 -0
  99. package/dist/zod-jitless.js.map +1 -0
  100. package/package.json +1 -1
  101. package/sidecar/server.mjs +73 -6
  102. package/skills/netjes/SKILL.md +13 -6
  103. package/skills/netjes/references/caching-and-streaming.md +37 -4
  104. package/skills/netjes/references/data-and-actions.md +9 -5
  105. package/skills/netjes/references/deployment.md +7 -3
  106. package/skills/netjes/references/routing.md +18 -6
  107. package/skills/netjes/references/testing.md +23 -7
  108. package/src/actions.tsx +9 -3
  109. package/src/client-entry.tsx +43 -2
  110. package/src/deferred.ts +14 -1
  111. package/src/document.tsx +28 -7
  112. package/src/errors.ts +12 -0
  113. package/src/form.tsx +10 -7
  114. package/src/href.ts +123 -8
  115. package/src/image.tsx +3 -3
  116. package/src/index.ts +2 -1
  117. package/src/link.tsx +47 -13
  118. package/src/metadata.tsx +19 -6
  119. package/src/modules.ts +15 -2
  120. package/src/navigation-focus.ts +75 -0
  121. package/src/payload.ts +2 -0
  122. package/src/query.tsx +14 -8
  123. package/src/render-document.tsx +386 -0
  124. package/src/route-announcer.tsx +69 -0
  125. package/src/route-tree.tsx +17 -6
  126. package/src/router.tsx +116 -31
  127. package/src/script.tsx +214 -23
  128. package/src/server-render.tsx +4 -1
  129. package/src/testing-vitest.ts +37 -0
  130. package/src/testing.tsx +31 -5
  131. package/src/virtual.d.ts +2 -0
  132. package/src/zod-jitless.ts +15 -0
  133. package/template/.dockerignore +3 -0
  134. package/template/Dockerfile +11 -1
  135. package/template/README.md +11 -7
  136. package/template/_AGENTS.md +36 -8
  137. package/template/__NAME__.csproj +2 -0
  138. package/template/app/document.test.ts +29 -0
  139. package/template/app/error.tsx +3 -2
  140. package/template/app/layout.tsx +10 -1
  141. package/template/docker-compose.override.yml +4 -3
  142. package/template/docker-compose.prod.yml +2 -1
  143. package/template/e2e/home.spec.ts +32 -0
  144. package/template/generated/netjes.ts +43 -14
  145. package/template/package.json +4 -1
  146. package/template/playwright.config.ts +36 -0
  147. package/template/tests/__NAME__.Tests/AppFactory.cs +31 -0
  148. package/template/tests/__NAME__.Tests/HomePageTests.cs +55 -0
  149. package/template/tests/__NAME__.Tests/__NAME__.Tests.csproj +30 -0
  150. package/template/tsconfig.app.json +20 -0
  151. package/template/tsconfig.json +2 -17
  152. package/template/tsconfig.node.json +8 -0
  153. package/vite/plugin.d.ts +9 -1
  154. package/vite/plugin.mjs +17 -5
@@ -1,12 +1,15 @@
1
- // Must come first: installs the React Fast Refresh runtime before any component module runs (empty in builds).
1
+ // Must come first, ahead of anything that could make a zod schema: turns zod's compiled parsers off (ADR 0036).
2
+ import './zod-jitless.ts';
3
+ // Must come before any component module: installs the React Fast Refresh runtime before one runs (empty in builds).
2
4
  import '@vitejs/plugin-react/preamble';
3
5
  import { createRoot, hydrateRoot } from 'react-dom/client';
4
6
  import { pages } from 'virtual:netjes/pages';
5
7
  import { deferredRegistry, revivePayload } from './deferred.ts';
6
8
  import { type RouteModules, createModuleLoader } from './modules.ts';
7
- import { DATA_ELEMENT_ID, parseInitialPayload } from './payload.ts';
9
+ import { DATA_ELEMENT_ID, SHELL_END_ID, parseInitialPayload } from './payload.ts';
8
10
  import { Root } from './root.tsx';
9
11
  import { withClockOffset } from './router.tsx';
12
+ import { BEFORE_INTERACTIVE_MARKER, adoptServerScripts } from './script.tsx';
10
13
 
11
14
  /**
12
15
  * The payload is the body's first element, so it is normally parsed by the time this async script runs. If the
@@ -36,6 +39,28 @@ function payloadElement(): Promise<HTMLElement | null> {
36
39
  });
37
40
  }
38
41
 
42
+ /**
43
+ * Resolves once the parser has passed the end of the shell, the empty element `Document` writes last in the body.
44
+ * This script is async, so a cached copy can run as soon as the payload is in, before most of the page is: hydrating
45
+ * then would meet half a document, give up and render the page in the browser, and the parser would add the rest of
46
+ * the server's HTML below it, so the page would show twice.
47
+ */
48
+ function shellParsed(): Promise<void> {
49
+ if (document.getElementById(SHELL_END_ID) || document.readyState !== 'loading') return Promise.resolve();
50
+ return new Promise((resolve) => {
51
+ const done = () => {
52
+ observer.disconnect();
53
+ document.removeEventListener('DOMContentLoaded', done);
54
+ resolve();
55
+ };
56
+ const observer = new MutationObserver(() => {
57
+ if (document.getElementById(SHELL_END_ID)) done();
58
+ });
59
+ observer.observe(document, { childList: true, subtree: true });
60
+ document.addEventListener('DOMContentLoaded', done);
61
+ });
62
+ }
63
+
39
64
  const dataJson = (await payloadElement())?.textContent;
40
65
  if (!dataJson) throw new Error(`netjes: #${DATA_ELEMENT_ID} is missing; was this page rendered by Netjes?`);
41
66
 
@@ -90,6 +115,18 @@ async function firstPageModules(): Promise<RouteModules | null> {
90
115
  }
91
116
  }
92
117
 
118
+ /**
119
+ * A `<Script strategy="beforeInteractive">` in the server's document puts a marker in the head, which is complete by
120
+ * the time the payload is read. Such a page waits for the end of the shell, where the parser has run the inline ones,
121
+ * then runs the external ones in order and waits for them, before the page's modules run and hydrate (ADR 0044). A
122
+ * page without one loads its modules while the rest of the shell arrives (ADR 0022).
123
+ */
124
+ const beforeInteractive = document.head.querySelector(`meta[name="${BEFORE_INTERACTIVE_MARKER}"]`) !== null;
125
+ if (beforeInteractive) {
126
+ await shellParsed();
127
+ await adoptServerScripts(payload.document.nonce);
128
+ }
129
+
93
130
  const modules = await firstPageModules();
94
131
  if (modules) {
95
132
  const root = <Root payload={payload} modules={modules} loader={loader} dataJson={dataJson} />;
@@ -98,6 +135,10 @@ if (modules) {
98
135
  // the scripts and stylesheets already in it and replaces the rest.
99
136
  createRoot(document).render(root);
100
137
  } else {
138
+ if (!beforeInteractive) {
139
+ await shellParsed();
140
+ await adoptServerScripts(payload.document.nonce);
141
+ }
101
142
  hydrateRoot(document, root);
102
143
  }
103
144
  }
package/src/deferred.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  // Deferred loader values (Page.Defer in C#): placeholders in the payload that become promises the page reads with
2
2
  // React's use() inside a Suspense boundary.
3
+ import { withheldError } from './errors.ts';
3
4
  import type { PagePayload, SegmentRef } from './payload.ts';
4
5
 
5
6
  /** A promise that records its outcome the way React's `use()` reads it, so a settled one renders without suspending. */
@@ -94,8 +95,9 @@ export function deferredRegistry() {
94
95
  resolve(id: string, value: unknown) {
95
96
  entry(id).resolve(value);
96
97
  },
98
+ /** Fails a value with the server's message; an empty one is a message the server withheld (no error details). */
97
99
  reject(id: string, message: string) {
98
- entry(id).reject(new Error(message));
100
+ entry(id).reject(message === '' ? withheldError() : new Error(message));
99
101
  },
100
102
  /** Rejects every value still pending, e.g. when the stream that was to deliver them ended. */
101
103
  rejectPending(message: (id: string) => string) {
@@ -115,6 +117,17 @@ export function deferredRegistry() {
115
117
  export const DEFERRED_BOOTSTRAP =
116
118
  "self.__netjes_d=self.__netjes_d||function(i,v,e){self.__netjes_v[i]=[v,e]};Object.prototype.hasOwnProperty.call(self,'__netjes_v')||(self.__netjes_v={})";
117
119
 
120
+ /**
121
+ * The `script-src` sources that allow Netjes's fixed inline scripts by their hashes, for a policy without a nonce: each
122
+ * `'sha256-…'` in its quotes, separated by spaces, as in `` `script-src 'self' ${CSP_SCRIPT_HASHES}` ``. A fixed script
123
+ * has the same text in every document. In this version there is one, the bootstrap that collects streamed `Page.Defer`
124
+ * values, and a Netjes version that changes it or adds another changes the value. The scripts that deliver those
125
+ * values, and React's scripts that reveal Suspense boundaries, differ from render to render, so no hash covers them.
126
+ * React's script for form actions that are functions is left out too, since its text follows the React version. The
127
+ * same value as `NetjesContentSecurityPolicy.ScriptHashes` in C#.
128
+ */
129
+ export const CSP_SCRIPT_HASHES: string = "'sha256-FSjVV+FzmXP8KKrLKLdvst/5N/bEWSkde01XzpGKRgs='";
130
+
118
131
  const UNSAFE = /[<>&\u2028\u2029]/g;
119
132
 
120
133
  /** JSON that can sit inside a <script> element. */
package/src/document.tsx CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import { DEFERRED_BOOTSTRAP } from './deferred.ts';
3
- import { DATA_ELEMENT_ID, type DocumentInfo } from './payload.ts';
3
+ import { DATA_ELEMENT_ID, type DocumentInfo, SHELL_END_ID } from './payload.ts';
4
4
 
5
5
  type DocumentProps = { info: DocumentInfo; dataJson: string; children: ReactNode };
6
6
 
@@ -8,14 +8,24 @@ type DocumentProps = { info: DocumentInfo; dataJson: string; children: ReactNode
8
8
  * The `<html>` shell around every page. React renders and hydrates the whole document, so `<title>`, `<meta>`
9
9
  * and `<link>` rendered by pages and layouts end up in the head.
10
10
  *
11
- * Documents stream, so the client entry is `async`: it hydrates the shell as soon as it loads, not once the last
12
- * Suspense boundary has arrived. Everything it reads comes before the content: the deferred-values bootstrap in the
13
- * head, the payload first in the body.
11
+ * Documents stream, so the client entry is `async`: it hydrates the shell as soon as the shell is in, not once the
12
+ * last Suspense boundary has arrived. Everything it reads comes before the content: the deferred-values bootstrap in
13
+ * the head, the payload first in the body. The empty element at the end of the body is the last thing in the shell,
14
+ * which the client entry waits for before it hydrates: a cached entry can run long before the parser gets there.
14
15
  */
15
16
  export function Document({ info, dataJson, children }: DocumentProps) {
16
17
  const { nonce } = info;
18
+ // Netjes owns <html> and <body>: the app's layout renders inside the body, so its markup cannot give either one an
19
+ // attribute. A script that runs before hydration can (a theme, a browser extension, a translator), and in development
20
+ // React logs a mismatch for each attribute on an element that its props do not have. suppressHydrationWarning ends
21
+ // that check for the element's own attributes. It also makes React accept any text that is the element's own child,
22
+ // in production too: text that a layout or a page renders outside any element or Suspense boundary sits directly in
23
+ // the body, and a mismatch there is neither reported nor repaired. The elements in the body are checked as before,
24
+ // their attributes and their text. React does not write the prop into the markup, and production does not check
25
+ // attributes at all. The check is off for `lang` too: the server and the client read it from the same payload, so it
26
+ // differs only when a script changed it, or when Netjes has a bug.
17
27
  return (
18
- <html lang={info.lang}>
28
+ <html lang={info.lang} suppressHydrationWarning>
19
29
  <head>
20
30
  <meta charSet="utf-8" />
21
31
  <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -27,8 +37,18 @@ export function Document({ info, dataJson, children }: DocumentProps) {
27
37
  {info.assets.stylesheets.map((href) => (
28
38
  <link key={href} rel="stylesheet" href={href} nonce={nonce} />
29
39
  ))}
40
+ {/* In development, the CSS the page imports, so that it is styled before its scripts run. Vite's client owns
41
+ each element's text once it runs: the CSS module with that id sets it as it loads, before hydration, and
42
+ again after every edit. The sidecar inlines the build that module carries, but a file edited between the
43
+ render and the import differs, so React leaves the text unchecked. */}
30
44
  {info.devStyles?.map((style) => (
31
- <style key={style.id} data-vite-dev-id={style.id} nonce={nonce} dangerouslySetInnerHTML={{ __html: style.css }} />
45
+ <style
46
+ key={style.id}
47
+ data-vite-dev-id={style.id}
48
+ nonce={nonce}
49
+ suppressHydrationWarning
50
+ dangerouslySetInnerHTML={{ __html: style.css }}
51
+ />
32
52
  ))}
33
53
  {info.assets.modulePreloads.map((href) => (
34
54
  <link key={href} rel="modulepreload" href={href} nonce={nonce} />
@@ -37,10 +57,11 @@ export function Document({ info, dataJson, children }: DocumentProps) {
37
57
  <script key={src} type="module" src={src} async nonce={nonce} />
38
58
  ))}
39
59
  </head>
40
- <body>
60
+ <body suppressHydrationWarning>
41
61
  {/* Already escaped for HTML by the server; the client reads it back verbatim to hydrate. */}
42
62
  <script id={DATA_ELEMENT_ID} type="application/json" nonce={nonce} dangerouslySetInnerHTML={{ __html: dataJson }} />
43
63
  {children}
64
+ <template id={SHELL_END_ID} />
44
65
  </body>
45
66
  </html>
46
67
  );
package/src/errors.ts ADDED
@@ -0,0 +1,12 @@
1
+ // The mark on errors whose message Netjes withheld: `withheld` is true when the message is Netjes's stand-in for one it
2
+ // does not show (error details are off, or the thrown value had none), so `!error.withheld` means it is worth showing.
3
+
4
+ /** Netjes's stand-in for a message it does not show, marked as one. */
5
+ export function withheldError(): Error & { withheld: true } {
6
+ return Object.assign(new Error('Something went wrong'), { withheld: true as const });
7
+ }
8
+
9
+ /** Whether `error` is marked as one whose message was withheld. */
10
+ export function isWithheld(error: unknown): boolean {
11
+ return error instanceof Error && (error as { withheld?: unknown }).withheld === true;
12
+ }
package/src/form.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import type { ButtonHTMLAttributes, FormHTMLAttributes, SubmitEvent } from 'react';
1
+ import type { ComponentPropsWithRef, SubmitEvent } from 'react';
2
2
  import { RETURN_FIELD, actionMetaOf } from './actions.tsx';
3
3
  import { encodeForm } from './form-data.ts';
4
4
  import type { Href } from './href.ts';
@@ -7,8 +7,8 @@ import { useBasePath, useInternalRouter, usePagePayload, withBasePath } from './
7
7
  /** A React form action, e.g. the `formAction` from `useAction(serverAction('name'))`. */
8
8
  export type FormAction = (formData: FormData) => void | Promise<void>;
9
9
 
10
- /** The props of `<Form>`: a form's own, with client navigation and server action support. */
11
- export type FormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'method' | 'action'> & {
10
+ /** The props of `<Form>`: a form's own and its ref, with client navigation and server action support. */
11
+ export type FormProps = Omit<ComponentPropsWithRef<'form'>, 'method' | 'action'> & {
12
12
  method?: 'get' | 'post';
13
13
  /**
14
14
  * Where to submit. Default: the current URL, which is where `MapPageAction` handlers for this page listen. Or a
@@ -74,6 +74,9 @@ export function Form({ method = 'get', action, replace = false, reloadDocument =
74
74
  }
75
75
 
76
76
  function handleSubmit(event: SubmitEvent<HTMLFormElement>) {
77
+ // Read before the app's own onSubmit, which runs as the form is submitted: where it moves the focus, the router
78
+ // leaves it, as after a link's onClick.
79
+ const focusedAtStart = document.activeElement;
77
80
  onSubmit?.(event);
78
81
  if (event.defaultPrevented || reloadDocument) return;
79
82
  const form = event.currentTarget;
@@ -88,7 +91,7 @@ export function Form({ method = 'get', action, replace = false, reloadDocument =
88
91
  const data = formDataOf(form, submitter);
89
92
  const submitMethod = (submitter?.getAttribute('formmethod') ?? method).toLowerCase();
90
93
  if (submitMethod === 'post') {
91
- void router.submit(url, encodeForm(data, form.enctype === 'multipart/form-data'));
94
+ void router.submit(url, encodeForm(data, form.enctype === 'multipart/form-data'), { focusedAtStart });
92
95
  return;
93
96
  }
94
97
  const query = new URLSearchParams();
@@ -97,7 +100,7 @@ export function Form({ method = 'get', action, replace = false, reloadDocument =
97
100
  url.hash = '';
98
101
  // Built from the form's own action, which is not one of the app's typed routes.
99
102
  const href = url.href as Href;
100
- void (replace ? router.replace(href) : router.push(href));
103
+ void (replace ? router.replace(href, { focusedAtStart }) : router.push(href, { focusedAtStart }));
101
104
  }
102
105
 
103
106
  return (
@@ -108,8 +111,8 @@ export function Form({ method = 'get', action, replace = false, reloadDocument =
108
111
  );
109
112
  }
110
113
 
111
- /** The props of `<SubmitButton>`: a button's own, except `type`. */
112
- export type SubmitButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'type'>;
114
+ /** The props of `<SubmitButton>`: a button's own and its ref, except `type`. */
115
+ export type SubmitButtonProps = Omit<ComponentPropsWithRef<'button'>, 'type'>;
113
116
 
114
117
  /**
115
118
  * A submit button whose `formAction` is rendered under the base path, so it posts to the right place with and
package/src/href.ts CHANGED
@@ -26,9 +26,13 @@ function matches(pattern: readonly string[], path: readonly string[]): boolean {
26
26
 
27
27
  /**
28
28
  * Whether a URL is one of the app's pages (by route pattern), a link within the page (`?…`, `#…`), or another
29
- * site. The generated `isHref()` and `Href` schema call this to check URLs that come from data.
29
+ * site. The generated `isHref()` and `Href` schema call this to check URLs that come from data. A URL with an ASCII
30
+ * control character, or with a space at either end, is none of these.
30
31
  */
31
32
  export function isRouteHref(value: string, patterns: readonly string[]): boolean {
33
+ // Browsers drop tabs and newlines anywhere in a URL, and control characters and spaces at either end: `/\t/host`
34
+ // would be `//host`. A C# Href refuses the same.
35
+ if (/[\u0000-\u001F\u007F]/.test(value) || value.startsWith(' ') || value.endsWith(' ')) return false;
32
36
  if (/^(https?:|mailto:|tel:)/i.test(value) || value.startsWith('?') || value.startsWith('#')) return true;
33
37
  // "//host" and "/\host" are other hosts as far as browsers are concerned.
34
38
  if (!value.startsWith('/') || value.startsWith('//') || value.startsWith('/\\')) return false;
@@ -36,14 +40,39 @@ export function isRouteHref(value: string, patterns: readonly string[]): boolean
36
40
  return patterns.some((pattern) => matches(pattern.split('/').filter(Boolean), path));
37
41
  }
38
42
 
43
+ /**
44
+ * A segment that would change the URL's path when a browser or `fetch` resolves it: `.` and `..` are dot segments,
45
+ * which move up the path, and an empty one doubles a slash, which right after the leading one names another host.
46
+ */
47
+ function changesThePath(segment: string): boolean {
48
+ return segment === '' || segment === '.' || segment === '..';
49
+ }
50
+
51
+ const changesThePathMessage = "a '.', '..' or empty segment would change the URL's path";
52
+
39
53
  /**
40
54
  * Builds the URL for a route pattern written in file-name syntax (`/blog/[slug]`, `/docs/[...path]`,
41
- * `/shop/[[...filters]]`), encoding every segment value. The generated `route()` helper calls this with typed params.
55
+ * `/shop/[[...filters]]`), encoding every segment value. A value, or a catch-all entry, that is `.`, `..` or empty
56
+ * throws, since it would change the path the URL names. `options.hash` adds a fragment after the query: the id of an
57
+ * element in the page, with one leading `#` dropped and the rest encoded; with nothing left, no fragment is added. The
58
+ * generated `route()` helper calls this with typed params.
42
59
  */
43
- export function buildHref(pattern: string, params: Record<string, unknown>, search?: SearchParamsInput): string {
60
+ export function buildHref(
61
+ pattern: string,
62
+ params: Record<string, unknown>,
63
+ search?: SearchParamsInput,
64
+ options?: { hash?: string | undefined },
65
+ ): string {
44
66
  const missing = (name: string): never => {
45
67
  throw new Error(`netjes: route '${pattern}' needs the parameter '${name}'`);
46
68
  };
69
+ const encode = (name: string, value: unknown): string => {
70
+ const text = String(value);
71
+ if (changesThePath(text)) {
72
+ throw new Error(`netjes: route '${pattern}' cannot take '${text}' for the parameter '${name}': ${changesThePathMessage}`);
73
+ }
74
+ return encodeURIComponent(text);
75
+ };
47
76
  const segments = pattern
48
77
  .split('/')
49
78
  .filter(Boolean)
@@ -51,29 +80,115 @@ export function buildHref(pattern: string, params: Record<string, unknown>, sear
51
80
  const optional = /^\[\[\.\.\.(\w+)\]\]$/.exec(segment)?.[1];
52
81
  if (optional) {
53
82
  const values = params[optional];
54
- return Array.isArray(values) ? values.map((v) => encodeURIComponent(String(v))) : [];
83
+ return Array.isArray(values) ? values.map((v) => encode(optional, v)) : [];
55
84
  }
56
85
  const catchAll = /^\[\.\.\.(\w+)\]$/.exec(segment)?.[1];
57
86
  if (catchAll) {
58
87
  const values = params[catchAll];
59
88
  if (!Array.isArray(values) || values.length === 0) return missing(catchAll);
60
- return values.map((v) => encodeURIComponent(String(v)));
89
+ return values.map((v) => encode(catchAll, v));
61
90
  }
62
91
  const single = /^\[(\w+)\]$/.exec(segment)?.[1];
63
92
  if (single) {
64
93
  const value = params[single];
65
94
  if (value === undefined || value === null || value === '') return missing(single);
66
- return [encodeURIComponent(String(value))];
95
+ return [encode(single, value)];
67
96
  }
68
97
  return [segment];
69
98
  });
70
99
 
71
100
  const query = new URLSearchParams();
101
+ appendSearch(query, search);
102
+ const queryString = query.toString();
103
+ const fragment = (options?.hash ?? '').replace(/^#/, '');
104
+ return `/${segments.join('/')}${queryString ? `?${queryString}` : ''}${fragment ? `#${encodeURIComponent(fragment)}` : ''}`;
105
+ }
106
+
107
+ /** Appends each value of `search`, a list repeating its key; `null` and `undefined` leave the key out. */
108
+ function appendSearch(query: URLSearchParams, search: Readonly<Record<string, unknown>> | undefined): void {
72
109
  for (const [key, value] of Object.entries(search ?? {})) {
73
- for (const item of Array.isArray(value) ? value : [value]) {
110
+ for (const item of Array.isArray(value) ? (value as readonly unknown[]) : [value]) {
74
111
  if (item !== undefined && item !== null) query.append(key, String(item));
75
112
  }
76
113
  }
114
+ }
115
+
116
+ /** A missing route value: `undefined`, `null` or `''`. */
117
+ function absent(value: unknown): boolean {
118
+ return value === undefined || value === null || value === '';
119
+ }
120
+
121
+ /**
122
+ * Builds the URL of one of the app's endpoints from its route template, as the generated file writes it: the
123
+ * literals as they are (with `%`, `{`, `}`, `#` and `\` encoded), `{name}`, `{name?}`, `{name=default}` (the default
124
+ * encoded), `{*name}` and `{**name}`, without constraints. Each route value is encoded; a `{**name}` value keeps its
125
+ * slashes, and a `{*name}` value has them encoded. A value that would make a segment `.` or `..`, or a `{**name}` value
126
+ * with such a piece or with an empty one before its end (`/x`, `a//b`), throws `netjes: endpoint '<endpoint>' cannot
127
+ * take '<value>' for the route value '<name>'`, since it would change the path the URL names. A missing, `null` or
128
+ * empty optional value drops its segment, or its
129
+ * part of a segment with the `.` before it (`{name}.{ext?}`); a missing value with a default is filled in, unless it
130
+ * is a whole segment at the end, which is dropped; a missing, `null` or empty required value, or an optional one whose
131
+ * segment is followed by one that stays, throws `netjes: endpoint '<endpoint>' needs the route value '<name>'`. The
132
+ * values the template does not name are the query string, followed by `search`. The result is root-relative and has no base path: pass it through
133
+ * `useHref` for a `fetch` or an `<a href>`. The generated `endpointUrl()` calls this with typed values.
134
+ */
135
+ export function buildEndpointUrl(
136
+ template: string,
137
+ values: Readonly<Record<string, unknown>>,
138
+ search?: SearchParamsInput,
139
+ endpoint?: string,
140
+ ): string {
141
+ const needs = (name: string) => new Error(`netjes: endpoint '${endpoint ?? template}' needs the route value '${name}'`);
142
+ const cannotTake = (name: string, value: unknown) =>
143
+ new Error(`netjes: endpoint '${endpoint ?? template}' cannot take '${String(value)}' for the route value '${name}': ${changesThePathMessage}`);
144
+ const named = new Set<string>();
145
+ // Each segment's text, and the name of the value it is missing, when the segment can go for want of it.
146
+ const segments: { text: string; missing: string | undefined }[] = [];
147
+ for (const segment of template.split('/').filter(Boolean)) {
148
+ let text = '';
149
+ let missing: string | undefined;
150
+ // The value that made the segment's text, which must not leave it a dot segment.
151
+ let filled: string | undefined;
152
+ for (const part of segment.split(/(\{[^}]*\})/).filter(Boolean)) {
153
+ const parameter = /^\{(\*\*?)?([^=?]+?)(\?|=([^]*))?\}$/.exec(part);
154
+ if (!parameter) {
155
+ text += part;
156
+ continue;
157
+ }
158
+
159
+ const [, star, name = '', marker, fallback] = parameter;
160
+ named.add(name);
161
+ const value = values[name];
162
+ if (!absent(value) && star === '**') {
163
+ // Each piece is a segment of its own; an empty last one is a trailing slash.
164
+ const pieces = String(value).split('/');
165
+ if (pieces.some((piece, i) => (piece === '' ? i < pieces.length - 1 : changesThePath(piece)))) throw cannotTake(name, value);
166
+ text += pieces.map((piece) => encodeURIComponent(piece)).join('/');
167
+ } else if (!absent(value)) {
168
+ text += encodeURIComponent(String(value));
169
+ filled = name;
170
+ } else if (fallback !== undefined) {
171
+ text += fallback;
172
+ if (part === segment) missing = name;
173
+ } else if (marker === '?' || star) {
174
+ // An optional value after a literal `.` takes the dot with it.
175
+ if (text.endsWith('.')) text = text.slice(0, -1);
176
+ if (!text) missing = name;
177
+ } else {
178
+ throw needs(name);
179
+ }
180
+ }
181
+ if (filled && changesThePath(text)) throw cannotTake(filled, values[filled]);
182
+ if (text || missing) segments.push({ text, missing });
183
+ }
184
+ while (segments.at(-1)?.missing) segments.pop();
185
+ // A segment left out before one that stays: routing would not match, or would read the next value as this one.
186
+ const leftOut = segments.find((segment) => !segment.text)?.missing;
187
+ if (leftOut) throw needs(leftOut);
188
+
189
+ const query = new URLSearchParams();
190
+ appendSearch(query, Object.fromEntries(Object.entries(values).filter(([key]) => !named.has(key))));
191
+ appendSearch(query, search);
77
192
  const queryString = query.toString();
78
- return `/${segments.join('/')}${queryString ? `?${queryString}` : ''}`;
193
+ return `/${segments.map((s) => s.text).join('/')}${queryString ? `?${queryString}` : ''}`;
79
194
  }
package/src/image.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { type ImgHTMLAttributes, createContext, useContext } from 'react';
1
+ import { type ComponentPropsWithRef, createContext, useContext } from 'react';
2
2
  import { preload } from 'react-dom';
3
3
 
4
4
  /**
@@ -17,8 +17,8 @@ const ImageConfigContext = createContext<ImageConfig>(DEFAULT_CONFIG);
17
17
 
18
18
  export const ImageConfigProvider = ImageConfigContext.Provider;
19
19
 
20
- /** The props of `<Image>`: an `<img>`'s own, with a required size and `alt`, and the optimizer's options. */
21
- export type ImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, 'src' | 'srcSet' | 'width' | 'height' | 'loading' | 'alt'> & {
20
+ /** The props of `<Image>`: an `<img>`'s own and its ref, with a required size and `alt`, and the optimizer's options. */
21
+ export type ImageProps = Omit<ComponentPropsWithRef<'img'>, 'src' | 'srcSet' | 'width' | 'height' | 'loading' | 'alt'> & {
22
22
  /** A path in `public/` (`/img/hero.png`) or a URL on a host in Netjes:Images:RemoteHosts. */
23
23
  src: string;
24
24
  /** Required, as for any image; `""` for decorative ones. */
package/src/index.ts CHANGED
@@ -9,10 +9,11 @@ export {
9
9
  usePageActionState,
10
10
  } from './actions.tsx';
11
11
  export { defineLayout, definePage } from './define-page.tsx';
12
+ export { CSP_SCRIPT_HASHES } from './deferred.ts';
12
13
  export { useDraftMode } from './draft-mode.ts';
13
14
  export { type DynamicOptions, clientOnly, dynamic } from './dynamic.tsx';
14
15
  export { Form, type FormAction, type FormProps, SubmitButton, type SubmitButtonProps } from './form.tsx';
15
- export { type Href, type Register, type SearchParamsInput, type SearchValue, buildHref, isRouteHref } from './href.ts';
16
+ export { type Href, type Register, type SearchParamsInput, type SearchValue, buildEndpointUrl, buildHref, isRouteHref } from './href.ts';
16
17
  export { Image, type ImageProps } from './image.tsx';
17
18
  export { Link, type LinkProps } from './link.tsx';
18
19
  export { useLocale } from './locale.ts';
package/src/link.tsx CHANGED
@@ -1,25 +1,50 @@
1
- import type { AnchorHTMLAttributes, MouseEvent } from 'react';
1
+ import type { ComponentPropsWithRef, MouseEvent } from 'react';
2
2
  import type { Href } from './href.ts';
3
- import { useBasePath, useInternalRouter, usePathname, withBasePath } from './router.tsx';
3
+ import { useBasePath, useInternalRouter, usePathname, useSearchParams, withBasePath } from './router.tsx';
4
4
 
5
- /** The props of `<Link>`: an anchor's own, with a typed `href` and the client navigation options. */
6
- export type LinkProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> & {
5
+ /** The props of `<Link>`: an anchor's own and its ref, with a typed `href` and the client navigation options. */
6
+ export type LinkProps = Omit<ComponentPropsWithRef<'a'>, 'href'> & {
7
7
  /** Typed by the app's routes once `netjes generate` has registered them. */
8
8
  href: Href;
9
9
  replace?: boolean;
10
- /** Scroll to the top (or the #fragment) after navigating. Default true. */
10
+ /**
11
+ * Scroll to the top after navigating, or to the #fragment's element, which then takes the focus. Default true. `false`
12
+ * keeps the scroll position, and the focus on the link while the link has it.
13
+ */
11
14
  scroll?: boolean;
12
- /** `intent` (default) fetches the next page on hover or focus. */
15
+ /** `intent` (default) fetches the next page on hover, focus or touch. */
13
16
  prefetch?: 'intent' | 'none';
14
- /** Let the browser follow it with a full page load, e.g. for a file download or a page outside Netjes. */
17
+ /** Let the browser follow it with a full page load: for a page of the app that must load in full, or a page on another site. */
15
18
  reloadDocument?: boolean;
16
19
  };
17
20
 
18
21
  const BASE = 'http://netjes.invalid';
19
22
 
20
- function isCurrentPath(href: string, pathname: string) {
21
- const url = new URL(href, BASE + pathname);
22
- return url.origin === BASE && url.pathname === pathname;
23
+ /** One trailing slash makes no other page: `/docs/` is `/docs`, and `/` stays `/`. */
24
+ function withoutTrailingSlash(path: string) {
25
+ return path.length > 1 && path.endsWith('/') ? path.slice(0, -1) : path;
26
+ }
27
+
28
+ /** The same values, in any order, as often each. */
29
+ function sameValues(a: string[], b: string[]) {
30
+ const sorted = [...b].sort();
31
+ return a.length === b.length && [...a].sort().every((value, i) => value === sorted[i]);
32
+ }
33
+
34
+ /**
35
+ * Whether the link goes to the page on screen. An href with a `#` points to a place in a page, so it never does.
36
+ * Otherwise its URL under the base path has the page's path, give or take one trailing slash, and the page's
37
+ * values for each query parameter it names; parameters it leaves out do not count. Only the path and the query
38
+ * enter, against a fixed origin, so the server and the browser agree.
39
+ */
40
+ function isCurrentPage(href: string, pathname: string, search: URLSearchParams, basePath: string) {
41
+ if (href.includes('#')) return false;
42
+ const query = search.toString();
43
+ // The page's URL as the browser has it: the base path, then the page's path, even one that starts like the base path.
44
+ const page = new URL(BASE + basePath + pathname + (query ? `?${query}` : ''));
45
+ const target = new URL(withBasePath(href, basePath), page);
46
+ if (target.origin !== BASE || withoutTrailingSlash(target.pathname) !== withoutTrailingSlash(page.pathname)) return false;
47
+ return [...new Set(target.searchParams.keys())].every((key) => sameValues(target.searchParams.getAll(key), search.getAll(key)));
23
48
  }
24
49
 
25
50
  function isPlainLeftClick(event: MouseEvent<HTMLAnchorElement>) {
@@ -35,10 +60,16 @@ function isPlainLeftClick(event: MouseEvent<HTMLAnchorElement>) {
35
60
  );
36
61
  }
37
62
 
38
- /** An `<a>` that navigates on the client. Ctrl/cmd-click, new tabs and external URLs behave like a normal link. */
63
+ /**
64
+ * An `<a>` that navigates on the client. Ctrl/cmd-click, new tabs and external URLs behave like a normal link. It sets
65
+ * `aria-current="page"` on a link to the page on screen: the same path, one trailing slash aside, and the page's values
66
+ * for each query parameter the link names. A link with a `#` is never marked, and an `aria-current` prop wins. After a
67
+ * navigation to another page, the router announces the page and starts the focus over at the top of the document.
68
+ */
39
69
  export function Link({ href, replace = false, scroll = true, prefetch = 'intent', reloadDocument = false, onClick, onMouseEnter, onFocus, onTouchStart, ...rest }: LinkProps) {
40
70
  const router = useInternalRouter();
41
71
  const pathname = usePathname();
72
+ const search = useSearchParams();
42
73
  const basePath = useBasePath();
43
74
  const warm = () => {
44
75
  if (prefetch === 'intent' && !reloadDocument) router.prefetch(href);
@@ -47,14 +78,17 @@ export function Link({ href, replace = false, scroll = true, prefetch = 'intent'
47
78
  return (
48
79
  <a
49
80
  href={withBasePath(href, basePath)}
50
- aria-current={isCurrentPath(href, pathname) ? 'page' : undefined}
81
+ aria-current={isCurrentPage(href, pathname, search, basePath) ? 'page' : undefined}
51
82
  {...rest}
52
83
  onClick={(event) => {
84
+ // Read before the app's own onClick, which runs as the link is followed: where it moves the focus, such as
85
+ // back to the button of a menu that closes, the router leaves it.
86
+ const focusedAtStart = document.activeElement;
53
87
  onClick?.(event);
54
88
  if (event.defaultPrevented || reloadDocument || !isPlainLeftClick(event)) return;
55
89
  if (new URL(href, window.location.href).origin !== window.location.origin) return;
56
90
  event.preventDefault();
57
- void (replace ? router.replace(href, { scroll }) : router.push(href, { scroll }));
91
+ void (replace ? router.replace(href, { scroll, focusedAtStart }) : router.push(href, { scroll, focusedAtStart }));
58
92
  }}
59
93
  onMouseEnter={(event) => {
60
94
  onMouseEnter?.(event);
package/src/metadata.tsx CHANGED
@@ -3,7 +3,11 @@
3
3
 
4
4
  import { Fragment } from 'react';
5
5
 
6
- /** A title, or a layout's `default` and `template` (`'%s · Site'`), or an `absolute` title that skips templates. */
6
+ /**
7
+ * A title, a layout's `template` (`'%s · Site'`) and `default`, or an `absolute` title that skips templates. A
8
+ * `default`, on a layout or a page, goes through the nearest `template` of the layouts above it like a string title,
9
+ * never through the one beside it; a layout's `default` is also the title of the pages below it that set none.
10
+ */
7
11
  export type MetadataTitle = string | { default?: string; template?: string; absolute?: string };
8
12
 
9
13
  /** An image for a link preview: `og:image` with `og:image:width`, `og:image:height` and `og:image:alt` when given, or `twitter:image` with `twitter:image:alt`. */
@@ -48,12 +52,18 @@ export type ResolvedMetadata = Omit<Metadata, 'title' | 'openGraph' | 'twitter'>
48
52
  twitter?: Omit<Twitter, 'images'> & { images?: ResolvedImage[] };
49
53
  };
50
54
 
55
+ /**
56
+ * The title that `title` stands for, given the template from the layouts above it. A string and a `default` go
57
+ * through the template, an `absolute` title does not, and an object with no `default` and no `absolute` (a layout
58
+ * that only sets a template) has no title of its own.
59
+ */
51
60
  function titleOf(title: MetadataTitle | undefined, template: string | undefined): string | undefined {
52
61
  if (title === undefined) return undefined;
53
62
  // A function replacer: a title such as "Save $& now" must not be read as a replacement pattern.
54
- if (typeof title === 'string') return template ? template.replace('%s', () => title) : title;
63
+ const inTemplate = (text: string) => (template ? template.replace('%s', () => text) : text);
64
+ if (typeof title === 'string') return inTemplate(title);
55
65
  if (title.absolute !== undefined) return title.absolute;
56
- return title.default;
66
+ return title.default === undefined ? undefined : inTemplate(title.default);
57
67
  }
58
68
 
59
69
  /** The images as a list of descriptors with string URLs; like Next.js, a null image or one without a URL is left out. */
@@ -72,7 +82,9 @@ function withImages<T extends { images?: MetadataImages }>(group: T): Omit<T, 'i
72
82
 
73
83
  /**
74
84
  * Merges the layouts' metadata (outermost first), the page's, and the server's. A layout's `title.template` applies
75
- * to titles below it, `title.default` is used when nothing below sets one, `title.absolute` skips templates.
85
+ * to the titles below it. `title.default`, on a layout or a page, goes through the templates above it like a string
86
+ * title and never through its own; on a layout it is the title when nothing below sets one. `title.absolute` skips
87
+ * templates.
76
88
  */
77
89
  export function resolveMetadata(layouts: readonly (Metadata | undefined)[], page: Metadata | undefined, server?: Metadata): ResolvedMetadata {
78
90
  let resolved: ResolvedMetadata = {};
@@ -82,8 +94,9 @@ export function resolveMetadata(layouts: readonly (Metadata | undefined)[], page
82
94
  const { title, openGraph, twitter, alternates, icons, other, ...rest } = metadata;
83
95
  resolved = { ...resolved, ...rest };
84
96
  if (title !== undefined) {
85
- // A layout's own default is not put through its own template, only templates from above.
86
- const own = titleOf(title, typeof title === 'string' || title.default === undefined ? template : undefined);
97
+ // The template from above applies to every title but an absolute one, a default included. This title's own
98
+ // template is stored after its title is written, so it applies only to the titles below.
99
+ const own = titleOf(title, template);
87
100
  if (own !== undefined) resolved.title = own;
88
101
  if (typeof title === 'object' && title.template) template = title.template;
89
102
  }