odoro 1.0.8 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +94 -0
  2. package/client.d.ts +90 -16
  3. package/dist/build-LZAUDJST.js +5 -0
  4. package/dist/{chunk-34RGOZFA.js → chunk-4D56Z7G3.js} +49 -50
  5. package/dist/{chunk-JMEHF3KN.js → chunk-7ZB5MAM6.js} +4 -4
  6. package/dist/{chunk-LHZGX5ML.js → chunk-OMHEQVIW.js} +295 -160
  7. package/dist/{chunk-3SZIN6VG.js → chunk-P3DJITWE.js} +10 -10
  8. package/dist/{chunk-ZVL7EXJO.js → chunk-PRWVZ2SM.js} +22 -14
  9. package/dist/chunk-Q2KCMTB5.js +741 -0
  10. package/dist/chunk-QOVBLN7A.js +241 -0
  11. package/dist/chunk-WWTDEV4Y.js +604 -0
  12. package/dist/cli.d.ts +12 -13
  13. package/dist/cli.js +84 -63
  14. package/dist/{commands-4JRBD55Z.js → commands-EUGVLQWL.js} +44 -45
  15. package/dist/{commands-AHMXBWQQ.js → commands-GYA7NTCX.js} +124 -124
  16. package/dist/{create-CDKS7KZ3.js → create-QX5LEWMS.js} +158 -154
  17. package/dist/index.d.ts +603 -151
  18. package/dist/index.js +6 -6
  19. package/dist/{package-NBP5NDX6.js → package-T4Z7OBOH.js} +5 -5
  20. package/dist/preview-P4WY5GEY.js +4 -0
  21. package/dist/registry/index.d.ts +75 -76
  22. package/dist/registry/index.js +1 -1
  23. package/dist/{server-4UGN3SFS.js → server-2WP562EV.js} +3 -3
  24. package/package.json +5 -5
  25. package/templates/react-ts/README.md +71 -35
  26. package/templates/react-ts/_env.example +9 -0
  27. package/templates/react-ts/_variants/with-engine/src/background.tsx +118 -0
  28. package/templates/react-ts/_variants/without-libs/src/App.tsx +380 -0
  29. package/templates/react-ts/_variants/without-libs/src/background.tsx +24 -0
  30. package/templates/react-ts/_variants/without-libs/src/entry-server.tsx +39 -0
  31. package/templates/react-ts/_variants/without-libs/src/main.tsx +26 -0
  32. package/templates/{react-ts-server/_variantes/sans-libs/client → react-ts/_variants/without-libs}/src/styles.css +137 -137
  33. package/templates/react-ts/{_variantes/sans-routeur → _variants/without-router}/src/App.tsx +141 -141
  34. package/templates/react-ts/_variants/without-router/src/entry-server.tsx +39 -0
  35. package/templates/react-ts/index.html +4 -4
  36. package/templates/react-ts/odoro.config.ts +5 -0
  37. package/templates/react-ts/src/App.tsx +219 -209
  38. package/templates/react-ts/src/background.tsx +66 -0
  39. package/templates/react-ts/src/entry-server.tsx +85 -0
  40. package/templates/react-ts/src/main.tsx +13 -4
  41. package/templates/react-ts/src/router.tsx +65 -45
  42. package/templates/react-ts/src/styles.css +5 -5
  43. package/templates/react-ts-server/Dockerfile +9 -9
  44. package/templates/react-ts-server/README.md +97 -45
  45. package/templates/react-ts-server/_env.example +31 -33
  46. package/templates/react-ts-server/_variants/with-engine/client/src/background.tsx +118 -0
  47. package/templates/react-ts-server/_variants/without-libs/client/src/App.tsx +380 -0
  48. package/templates/react-ts-server/_variants/without-libs/client/src/background.tsx +24 -0
  49. package/templates/react-ts-server/_variants/without-libs/client/src/entry-server.tsx +39 -0
  50. package/templates/react-ts-server/_variants/without-libs/client/src/main.tsx +26 -0
  51. package/templates/{react-ts/_variantes/sans-libs → react-ts-server/_variants/without-libs/client}/src/styles.css +137 -137
  52. package/templates/react-ts-server/{_variantes/sans-routeur → _variants/without-router}/client/src/App.tsx +141 -141
  53. package/templates/react-ts-server/_variants/without-router/client/src/entry-server.tsx +39 -0
  54. package/templates/react-ts-server/client/index.html +4 -4
  55. package/templates/react-ts-server/client/src/App.tsx +228 -210
  56. package/templates/react-ts-server/client/src/account.tsx +267 -0
  57. package/templates/react-ts-server/client/src/auth.tsx +139 -0
  58. package/templates/react-ts-server/client/src/background.tsx +66 -0
  59. package/templates/react-ts-server/client/src/entry-server.tsx +92 -0
  60. package/templates/react-ts-server/client/src/main.tsx +17 -5
  61. package/templates/react-ts-server/client/src/router.tsx +77 -45
  62. package/templates/react-ts-server/client/src/styles.css +5 -5
  63. package/templates/react-ts-server/odoro.config.ts +7 -4
  64. package/templates/react-ts-server/package.json +2 -0
  65. package/templates/react-ts-server/scripts/dev.mjs +14 -14
  66. package/templates/react-ts-server/server/src/main.ts +119 -45
  67. package/templates/react-ts-server/server/src/modules/auth/index.ts +238 -0
  68. package/templates/react-ts-server/server/src/modules/auth/password.ts +122 -0
  69. package/templates/react-ts-server/server/src/modules/auth/store.ts +193 -0
  70. package/templates/react-ts-server/server/src/modules/health/index.ts +45 -46
  71. package/dist/build-JFQHODAT.js +0 -5
  72. package/dist/chunk-22KJTV2R.js +0 -380
  73. package/dist/chunk-DL3NPC4H.js +0 -113
  74. package/dist/chunk-TQUJ3MFS.js +0 -268
  75. package/dist/preview-7DKEAQOQ.js +0 -4
  76. package/templates/react-ts/_variantes/avec-moteur/src/fond.tsx +0 -118
  77. package/templates/react-ts/_variantes/sans-libs/src/App.tsx +0 -382
  78. package/templates/react-ts/_variantes/sans-libs/src/fond.tsx +0 -23
  79. package/templates/react-ts/_variantes/sans-libs/src/main.tsx +0 -17
  80. package/templates/react-ts/src/fond.tsx +0 -42
  81. package/templates/react-ts-server/_variantes/avec-moteur/client/src/fond.tsx +0 -118
  82. package/templates/react-ts-server/_variantes/sans-libs/client/src/App.tsx +0 -382
  83. package/templates/react-ts-server/_variantes/sans-libs/client/src/fond.tsx +0 -23
  84. package/templates/react-ts-server/_variantes/sans-libs/client/src/main.tsx +0 -17
  85. package/templates/react-ts-server/client/src/fond.tsx +0 -42
@@ -0,0 +1,267 @@
1
+ /**
2
+ * The three account pages: sign in, register, profile.
3
+ *
4
+ * ## Why they live apart from `App.tsx`
5
+ *
6
+ * `App.tsx` holds the page you see before signing in, and it is meant to be
7
+ * read in one go. These three depend on the back end: they are the part to
8
+ * open when wiring the API, and the part to delete when the project does not
9
+ * need accounts.
10
+ *
11
+ * ## What the form does about errors
12
+ *
13
+ * It shows what the server said, and nothing else. The foundation answers in
14
+ * `problem+json`, where `detail` is a sentence written for a human — "This
15
+ * address already has an account." A status code in its place would be a
16
+ * number in front of someone who mistyped their email.
17
+ *
18
+ * @module
19
+ */
20
+
21
+ import { buttonClasses } from '@odoro-cli/libs/ui'
22
+ import { useState, type FormEvent, type ReactElement, type ReactNode } from 'react'
23
+
24
+ import { useSession } from '@/auth'
25
+ import { Link } from '@/router'
26
+
27
+ /** The frame the three pages share. */
28
+ function Panel({
29
+ title,
30
+ lead,
31
+ children,
32
+ }: {
33
+ readonly title: string
34
+ readonly lead: string
35
+ readonly children: ReactNode
36
+ }): ReactElement {
37
+ return (
38
+ <section className="o-px-6 o-py-24">
39
+ <div className="o-mx-auto o-w-full o-max-w-md">
40
+ <h1 className="o-text-3xl o-font-bold o-tracking-tight">{title}</h1>
41
+ <p className="o-mt-3 o-text-sm o-text-zinc-500 dark:o-text-zinc-400">{lead}</p>
42
+ <div className="o-mt-8">{children}</div>
43
+ </div>
44
+ </section>
45
+ )
46
+ }
47
+
48
+ /** A labelled field. */
49
+ function Field({
50
+ label,
51
+ type,
52
+ value,
53
+ onChange,
54
+ autoComplete,
55
+ hint,
56
+ }: {
57
+ readonly label: string
58
+ readonly type: string
59
+ readonly value: string
60
+ readonly onChange: (value: string) => void
61
+ readonly autoComplete: string
62
+ readonly hint?: string
63
+ }): ReactElement {
64
+ return (
65
+ <label className="o-block o-mb-4">
66
+ <span className="o-block o-mb-1 o-text-sm o-font-medium">{label}</span>
67
+ <input
68
+ type={type}
69
+ value={value}
70
+ required
71
+ autoComplete={autoComplete}
72
+ onChange={(event) => onChange(event.target.value)}
73
+ className="o-w-full o-rounded-lg o-border o-border-zinc-200 dark:o-border-zinc-800 o-bg-transparent o-px-3 o-py-2 o-text-sm"
74
+ />
75
+ {hint === undefined ? null : (
76
+ <span className="o-mt-1 o-block o-text-xs o-text-zinc-500 dark:o-text-zinc-400">
77
+ {hint}
78
+ </span>
79
+ )}
80
+ </label>
81
+ )
82
+ }
83
+
84
+ /** The error of a submission, when there is one. */
85
+ function Problem({ message }: { readonly message: string | null }): ReactElement | null {
86
+ if (message === null) return null
87
+ return (
88
+ <p
89
+ // `alert` so a screen reader announces it: a message that only appears
90
+ // visually leaves whoever does not see it waiting on a form that did
91
+ // nothing.
92
+ role="alert"
93
+ className="o-mb-4 o-rounded-lg o-border o-border-red-300 dark:o-border-red-900 o-px-3 o-py-2 o-text-sm o-text-red-700 dark:o-text-red-400"
94
+ >
95
+ {message}
96
+ </p>
97
+ )
98
+ }
99
+
100
+ /** Runs a submission, holding the pending state and the error. */
101
+ function useSubmission(): {
102
+ busy: boolean
103
+ error: string | null
104
+ run: (action: () => Promise<void>) => (event: FormEvent) => void
105
+ } {
106
+ const [busy, setBusy] = useState(false)
107
+ const [error, setError] = useState<string | null>(null)
108
+
109
+ const run = (action: () => Promise<void>) => (event: FormEvent) => {
110
+ event.preventDefault()
111
+ setBusy(true)
112
+ setError(null)
113
+ action()
114
+ .catch((cause: unknown) => {
115
+ setError(cause instanceof Error ? cause.message : 'The request failed.')
116
+ })
117
+ .finally(() => setBusy(false))
118
+ }
119
+
120
+ return { busy, error, run }
121
+ }
122
+
123
+ /** Signing in. */
124
+ export function SignIn(): ReactElement {
125
+ const { profile, signIn } = useSession()
126
+ const [email, setEmail] = useState('')
127
+ const [password, setPassword] = useState('')
128
+ const { busy, error, run } = useSubmission()
129
+
130
+ if (profile != null) return <AlreadySignedIn name={profile.name} />
131
+
132
+ return (
133
+ <Panel title="Sign in" lead="The session is a cookie: nothing to keep in the page.">
134
+ <form onSubmit={run(() => signIn(email, password))}>
135
+ <Problem message={error} />
136
+ <Field
137
+ label="Email"
138
+ type="email"
139
+ value={email}
140
+ onChange={setEmail}
141
+ autoComplete="email"
142
+ />
143
+ <Field
144
+ label="Password"
145
+ type="password"
146
+ value={password}
147
+ onChange={setPassword}
148
+ autoComplete="current-password"
149
+ />
150
+ <button type="submit" disabled={busy} className={buttonClasses({ tone: 'primary' })}>
151
+ {busy ? 'Signing in…' : 'Sign in'}
152
+ </button>
153
+ </form>
154
+
155
+ <p className="o-mt-6 o-text-sm o-text-zinc-500 dark:o-text-zinc-400">
156
+ No account yet? <Link to="/register">Create one</Link>.
157
+ </p>
158
+ </Panel>
159
+ )
160
+ }
161
+
162
+ /** Registering. */
163
+ export function Register(): ReactElement {
164
+ const { profile, register } = useSession()
165
+ const [email, setEmail] = useState('')
166
+ const [name, setName] = useState('')
167
+ const [password, setPassword] = useState('')
168
+ const { busy, error, run } = useSubmission()
169
+
170
+ if (profile != null) return <AlreadySignedIn name={profile.name} />
171
+
172
+ return (
173
+ <Panel
174
+ title="Create an account"
175
+ lead="The password is hashed with scrypt, and never leaves the server in clear."
176
+ >
177
+ <form onSubmit={run(() => register(email, name, password))}>
178
+ <Problem message={error} />
179
+ <Field label="Name" type="text" value={name} onChange={setName} autoComplete="name" />
180
+ <Field
181
+ label="Email"
182
+ type="email"
183
+ value={email}
184
+ onChange={setEmail}
185
+ autoComplete="email"
186
+ />
187
+ <Field
188
+ label="Password"
189
+ type="password"
190
+ value={password}
191
+ onChange={setPassword}
192
+ autoComplete="new-password"
193
+ hint="Twelve characters at least. Length is what holds; a mix of symbols is not."
194
+ />
195
+ <button type="submit" disabled={busy} className={buttonClasses({ tone: 'primary' })}>
196
+ {busy ? 'Creating…' : 'Create the account'}
197
+ </button>
198
+ </form>
199
+
200
+ <p className="o-mt-6 o-text-sm o-text-zinc-500 dark:o-text-zinc-400">
201
+ Already have one? <Link to="/sign-in">Sign in</Link>.
202
+ </p>
203
+ </Panel>
204
+ )
205
+ }
206
+
207
+ /** The profile of the signed-in account. */
208
+ export function ProfilePage(): ReactElement {
209
+ const { profile, signOut } = useSession()
210
+
211
+ // Not asked yet: showing the signed-out page here would flash it at every
212
+ // reload, including to someone who is signed in.
213
+ if (profile === undefined) {
214
+ return <Panel title="Profile" lead="Reading the session…" children={null} />
215
+ }
216
+
217
+ if (profile === null) {
218
+ return (
219
+ <Panel title="Profile" lead="This page needs an account.">
220
+ <Link to="/sign-in" className={`o-no-underline ${buttonClasses({ tone: 'primary' })}`}>
221
+ Sign in
222
+ </Link>
223
+ </Panel>
224
+ )
225
+ }
226
+
227
+ return (
228
+ <Panel title={profile.name} lead="What the server knows about this account.">
229
+ <dl className="o-border-t o-border-zinc-200 dark:o-border-zinc-800">
230
+ {[
231
+ ['Email', profile.email],
232
+ ['Identifier', profile.id],
233
+ ['Created', new Date(profile.createdAt).toLocaleString()],
234
+ ].map(([label, value]) => (
235
+ <div
236
+ key={label}
237
+ className="o-flex o-gap-6 o-border-b o-border-zinc-200 dark:o-border-zinc-800 o-py-3"
238
+ >
239
+ <dt className="o-w-28 o-shrink-0 o-text-sm o-text-zinc-500 dark:o-text-zinc-400">
240
+ {label}
241
+ </dt>
242
+ <dd className="o-break-all o-font-mono o-text-sm">{value}</dd>
243
+ </div>
244
+ ))}
245
+ </dl>
246
+
247
+ <button
248
+ type="button"
249
+ onClick={() => void signOut()}
250
+ className={`o-mt-8 ${buttonClasses({ tone: 'secondary' })}`}
251
+ >
252
+ Sign out
253
+ </button>
254
+ </Panel>
255
+ )
256
+ }
257
+
258
+ /** Shown on the two forms when the visitor already has a session. */
259
+ function AlreadySignedIn({ name }: { readonly name: string }): ReactElement {
260
+ return (
261
+ <Panel title={`Signed in as ${name}`} lead="Nothing to do here.">
262
+ <Link to="/profile" className={`o-no-underline ${buttonClasses({ tone: 'primary' })}`}>
263
+ See the profile
264
+ </Link>
265
+ </Panel>
266
+ )
267
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * The session, client side.
3
+ *
4
+ * ## Why there is no token here
5
+ *
6
+ * The session lives in an `httpOnly` cookie that the browser sends on its own.
7
+ * This file never sees it, and that is the point: a value a script can read is
8
+ * a value the first injected script can steal.
9
+ *
10
+ * So the only question the interface can ask is "who am I", and the only way
11
+ * to ask it is `GET /api/auth/me`. That is one request at startup, and the
12
+ * answer is kept for the life of the page.
13
+ *
14
+ * ## Why the state has three values and not two
15
+ *
16
+ * `undefined` is "not asked yet", `null` is "asked, nobody". Collapsing them
17
+ * would flash the signed-out interface at every reload, before the answer
18
+ * arrives — including to someone who is signed in.
19
+ *
20
+ * @module
21
+ */
22
+
23
+ import {
24
+ createContext,
25
+ useCallback,
26
+ useContext,
27
+ useEffect,
28
+ useMemo,
29
+ useState,
30
+ type ReactElement,
31
+ type ReactNode,
32
+ } from 'react'
33
+
34
+ /** A profile, as the API returns it. */
35
+ export interface Profile {
36
+ readonly id: string
37
+ readonly email: string
38
+ readonly name: string
39
+ readonly createdAt: string
40
+ }
41
+
42
+ /** What the interface can do with the session. */
43
+ export interface Session {
44
+ /** `undefined` while unknown, `null` when signed out. */
45
+ readonly profile: Profile | null | undefined
46
+ register(email: string, name: string, password: string): Promise<void>
47
+ signIn(email: string, password: string): Promise<void>
48
+ signOut(): Promise<void>
49
+ }
50
+
51
+ const SessionContext = createContext<Session | undefined>(undefined)
52
+
53
+ /**
54
+ * A failed call, with the message the server gave.
55
+ *
56
+ * The foundation answers in `application/problem+json`: `title` says what
57
+ * happened, `detail` says why. Showing the HTTP status instead would put "409"
58
+ * in front of someone who typed an address that already has an account.
59
+ */
60
+ async function call<T>(path: string, body?: unknown): Promise<T> {
61
+ const response = await fetch(path, {
62
+ method: body === undefined ? 'GET' : 'POST',
63
+ // The cookie is only sent if it is asked for. Without this, every call is
64
+ // anonymous and the session looks broken for no visible reason.
65
+ credentials: 'same-origin',
66
+ ...(body === undefined
67
+ ? {}
68
+ : { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }),
69
+ })
70
+
71
+ if (response.status === 204) return undefined as T
72
+
73
+ const payload: unknown = await response.json().catch(() => undefined)
74
+
75
+ if (!response.ok) {
76
+ const problem = payload as { detail?: string; title?: string } | undefined
77
+ throw new Error(problem?.detail ?? problem?.title ?? 'The request failed.')
78
+ }
79
+
80
+ return payload as T
81
+ }
82
+
83
+ /** Provides the session to the whole tree. */
84
+ export function SessionProvider({ children }: { children: ReactNode }): ReactElement {
85
+ const [profile, setProfile] = useState<Profile | null | undefined>(undefined)
86
+
87
+ useEffect(() => {
88
+ let alive = true
89
+ call<Profile>('/api/auth/me')
90
+ .then((me) => {
91
+ if (alive) setProfile(me)
92
+ })
93
+ .catch(() => {
94
+ // Not signed in, or the server has nothing to answer with. Either way
95
+ // the interface shows the signed-out state; it is not an incident.
96
+ if (alive) setProfile(null)
97
+ })
98
+ return () => {
99
+ alive = false
100
+ }
101
+ }, [])
102
+
103
+ const register = useCallback(async (email: string, name: string, password: string) => {
104
+ setProfile(await call<Profile>('/api/auth/register', { email, name, password }))
105
+ }, [])
106
+
107
+ const signIn = useCallback(async (email: string, password: string) => {
108
+ setProfile(await call<Profile>('/api/auth/login', { email, password }))
109
+ }, [])
110
+
111
+ const signOut = useCallback(async () => {
112
+ await call<void>('/api/auth/logout', {})
113
+ setProfile(null)
114
+ }, [])
115
+
116
+ const value = useMemo<Session>(
117
+ () => ({ profile, register, signIn, signOut }),
118
+ [profile, register, signIn, signOut],
119
+ )
120
+
121
+ return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>
122
+ }
123
+
124
+ /**
125
+ * Reads the session.
126
+ *
127
+ * @throws {Error} Outside `SessionProvider` — which is a wiring mistake, and
128
+ * is better said than silently returning "signed out".
129
+ *
130
+ * @example
131
+ * const { profile, signOut } = useSession()
132
+ */
133
+ export function useSession(): Session {
134
+ const session = useContext(SessionContext)
135
+ if (session === undefined) {
136
+ throw new Error('useSession used outside SessionProvider.')
137
+ }
138
+ return session
139
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The decorative background of the page.
3
+ *
4
+ * ## Why it is not in `App.tsx`
5
+ *
6
+ * It is the only piece of the page that depends on what was ticked at
7
+ * creation: with the engine, this file is replaced by a version that opens an
8
+ * animated WebGL surface; without it, it is the gradient below.
9
+ *
10
+ * Keeping it apart avoids having two `App.tsx` files to maintain — one per
11
+ * case — which would diverge at the first change of wording. `App.tsx` places
12
+ * `<Background />` and asks for nothing more.
13
+ *
14
+ * ## Why the sizes are inline styles
15
+ *
16
+ * The style system **emits no arbitrary-value class**: `o-h-[42rem]` produces
17
+ * no rule at all, and a missing class paints nothing. The container ended up
18
+ * zero pixels tall, so did both glows, and the background was nowhere to be
19
+ * seen — with nothing to report it.
20
+ *
21
+ * The utilities cover the values on the scale; anything off the scale is
22
+ * written as a style, where it is safe.
23
+ *
24
+ * @module
25
+ */
26
+
27
+ import type { ReactElement } from 'react'
28
+
29
+ /** Two brand glows, heavily diluted, behind the top of the page. */
30
+ export function Background(): ReactElement {
31
+ return (
32
+ <div
33
+ aria-hidden="true"
34
+ className="o-pointer-events-none o-absolute o-inset-x-0 o-top-0 o-overflow-hidden"
35
+ style={{ height: '42rem' }}
36
+ >
37
+ <div
38
+ className="o-absolute o-rounded-full"
39
+ style={{
40
+ left: '50%',
41
+ top: '-18rem',
42
+ width: '46rem',
43
+ height: '46rem',
44
+ transform: 'translateX(-50%)',
45
+ filter: 'blur(64px)',
46
+ opacity: 0.3,
47
+ background:
48
+ 'radial-gradient(closest-side, var(--o-palette-brand-500), transparent)',
49
+ }}
50
+ />
51
+ <div
52
+ className="o-absolute o-rounded-full"
53
+ style={{
54
+ right: '-10rem',
55
+ top: '6rem',
56
+ width: '30rem',
57
+ height: '30rem',
58
+ filter: 'blur(64px)',
59
+ opacity: 0.18,
60
+ background:
61
+ 'radial-gradient(closest-side, var(--o-palette-brand-400), transparent)',
62
+ }}
63
+ />
64
+ </div>
65
+ )
66
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The prerendering entry point.
3
+ *
4
+ * ## What it does, and why it exists
5
+ *
6
+ * `odoro build` renders each of the routes below to HTML, at build time, and
7
+ * writes one complete document per route. The site stays a set of static files
8
+ * — nothing runs in production — but a search engine, a link preview or a
9
+ * screen reader find text in the very first response, instead of an empty
10
+ * container.
11
+ *
12
+ * `src/main.tsx` then takes over: seeing a container already filled, it
13
+ * hydrates instead of rebuilding.
14
+ *
15
+ * ## Adding a route
16
+ *
17
+ * A single line in `routes`. The path becomes a directory in `dist/`:
18
+ * `/pricing` gives `dist/pricing/index.html`, which any host serves with no
19
+ * configuration.
20
+ *
21
+ * ## Doing without
22
+ *
23
+ * Remove `prerender` from `odoro.config.ts`. This file can then go: nothing
24
+ * else imports it.
25
+ *
26
+ * @module
27
+ */
28
+
29
+ import { StrictMode } from 'react'
30
+ import { renderToString } from 'react-dom/server'
31
+
32
+ import { App } from '@/App'
33
+ import { SessionProvider } from '@/auth'
34
+
35
+ /** The routes rendered at build time. */
36
+ export const routes = ['/', '/about']
37
+
38
+ /** What a page brings to the head of the document. */
39
+ const TITLES: Readonly<Record<string, { title: string; description: string }>> = {
40
+ '/': {
41
+ title: 'Odoro',
42
+ description: 'An Odoro project: in-house engine, generated styles, built-in router.',
43
+ },
44
+ '/about': {
45
+ title: 'About — Odoro',
46
+ description: 'What this project ships, and what each package does in it.',
47
+ },
48
+ }
49
+
50
+ /** Escapes what goes into an HTML attribute. */
51
+ function escape(text: string): string {
52
+ return text
53
+ .replace(/&/g, '&amp;')
54
+ .replace(/</g, '&lt;')
55
+ .replace(/>/g, '&gt;')
56
+ .replace(/"/g, '&quot;')
57
+ }
58
+
59
+ /**
60
+ * Renders a route.
61
+ *
62
+ * @param url The requested path.
63
+ * @returns The HTML of the container, and the tags to add to the head.
64
+ */
65
+ export function render(url: string): { html: string; head: string } {
66
+ const meta = TITLES[url] ?? TITLES['/']
67
+
68
+ const head = [
69
+ `<title>${escape(meta?.title ?? 'Odoro')}</title>`,
70
+ `<meta name="description" content="${escape(meta?.description ?? '')}">`,
71
+ `<meta property="og:title" content="${escape(meta?.title ?? 'Odoro')}">`,
72
+ `<meta property="og:description" content="${escape(meta?.description ?? '')}">`,
73
+ ].join('\n ')
74
+
75
+ return {
76
+ // `StrictMode` is present on both sides: a tree rendered without it here
77
+ // and with it in the browser does not always produce the same markup, and
78
+ // hydration then reports a difference that is not one.
79
+ html: renderToString(
80
+ <StrictMode>
81
+ {/* The same tree as in the browser. The provider asks the server
82
+ nothing here — its request lives in an effect, and effects do not
83
+ run while prerendering — but a page that reads the session must
84
+ find it, or the whole prerender fails on that one page. */}
85
+ <SessionProvider>
86
+ <App url={url} />
87
+ </SessionProvider>
88
+ </StrictMode>,
89
+ ),
90
+ head,
91
+ }
92
+ }
@@ -1,18 +1,30 @@
1
1
  import { StrictMode } from 'react'
2
- import { createRoot } from 'react-dom/client'
2
+ import { createRoot, hydrateRoot } from 'react-dom/client'
3
3
 
4
4
  import { App } from '@/App'
5
+ import { SessionProvider } from '@/auth'
5
6
 
6
7
  import '@odoro-cli/libs/styles.css'
7
8
  import '@/styles.css'
8
9
 
9
10
  const container = document.getElementById('root')
10
11
  if (container === null) {
11
- throw new Error('Element racine "#root" introuvable dans index.html.')
12
+ throw new Error('Root element "#root" not found in index.html.')
12
13
  }
13
14
 
14
- createRoot(container).render(
15
+ const tree = (
15
16
  <StrictMode>
16
- <App />
17
- </StrictMode>,
17
+ <SessionProvider>
18
+ <App />
19
+ </SessionProvider>
20
+ </StrictMode>
18
21
  )
22
+
23
+ // A container that is already filled comes from prerendering: it has to be
24
+ // **hydrated**, that is, taken over as it stands with the events attached.
25
+ // Rebuilding it would throw away the page the visitor already sees only to draw
26
+ // it again identically — a flicker, and the whole benefit of prerendering lost.
27
+ //
28
+ // Empty, it is an ordinary render.
29
+ if (container.firstElementChild === null) createRoot(container).render(tree)
30
+ else hydrateRoot(container, tree)