create-vitnode-app 0.0.1-canary.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 (90) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +78 -0
  3. package/copy-of-vitnode-app/.vscode/settings.json +10 -0
  4. package/copy-of-vitnode-app/README.md +40 -0
  5. package/copy-of-vitnode-app/api/.gitignore_template +32 -0
  6. package/copy-of-vitnode-app/api/drizzle.config.ts +12 -0
  7. package/copy-of-vitnode-app/api/src/i18n.ts +38 -0
  8. package/copy-of-vitnode-app/api/src/index.ts +27 -0
  9. package/copy-of-vitnode-app/api/src/vitnode.api.config.ts +40 -0
  10. package/copy-of-vitnode-app/api/tsconfig.json +17 -0
  11. package/copy-of-vitnode-app/api-bun/src/index.ts +16 -0
  12. package/copy-of-vitnode-app/api-single-app/drizzle.config.ts +12 -0
  13. package/copy-of-vitnode-app/api-single-app/src/routes/api/$.ts +32 -0
  14. package/copy-of-vitnode-app/api-single-app/src/server/api-bridge.ts +22 -0
  15. package/copy-of-vitnode-app/api-single-app/src/server/vitnode-api.server.ts +29 -0
  16. package/copy-of-vitnode-app/api-single-app/src/vitnode.api.config.ts +37 -0
  17. package/copy-of-vitnode-app/docker/docker-compose.yml +30 -0
  18. package/copy-of-vitnode-app/eslint/.prettierrc.mjs +11 -0
  19. package/copy-of-vitnode-app/eslint/eslint.config.mjs +19 -0
  20. package/copy-of-vitnode-app/eslint-react/.prettierrc.mjs +11 -0
  21. package/copy-of-vitnode-app/eslint-react/eslint.config.mjs +19 -0
  22. package/copy-of-vitnode-app/monorepo/.gitignore_template +41 -0
  23. package/copy-of-vitnode-app/monorepo/apps/api/.env.example +12 -0
  24. package/copy-of-vitnode-app/monorepo/apps/web/.env.example +12 -0
  25. package/copy-of-vitnode-app/monorepo/turbo.json +53 -0
  26. package/copy-of-vitnode-app/root/.env.example +22 -0
  27. package/copy-of-vitnode-app/root/.gitignore_template +41 -0
  28. package/copy-of-vitnode-app/root/global.d.ts +28 -0
  29. package/copy-of-vitnode-app/root/src/components/admin-shell.tsx +72 -0
  30. package/copy-of-vitnode-app/root/src/components/main-header.tsx +33 -0
  31. package/copy-of-vitnode-app/root/src/i18n.ts +36 -0
  32. package/copy-of-vitnode-app/root/src/lib/admin-auth.ts +11 -0
  33. package/copy-of-vitnode-app/root/src/lib/admin-nav.ts +43 -0
  34. package/copy-of-vitnode-app/root/src/lib/admin-search.ts +37 -0
  35. package/copy-of-vitnode-app/root/src/lib/auth.ts +66 -0
  36. package/copy-of-vitnode-app/root/src/lib/content-registry.ts +51 -0
  37. package/copy-of-vitnode-app/root/src/lib/document-headers.ts +128 -0
  38. package/copy-of-vitnode-app/root/src/lib/i18n/runtime.ts +75 -0
  39. package/copy-of-vitnode-app/root/src/lib/i18n/shared.ts +20 -0
  40. package/copy-of-vitnode-app/root/src/lib/navigation.ts +24 -0
  41. package/copy-of-vitnode-app/root/src/lib/page-head.ts +19 -0
  42. package/copy-of-vitnode-app/root/src/locales/app.ts +28 -0
  43. package/copy-of-vitnode-app/root/src/locales/packages.ts +43 -0
  44. package/copy-of-vitnode-app/root/src/router.tsx +227 -0
  45. package/copy-of-vitnode-app/root/src/routes/__root.tsx +212 -0
  46. package/copy-of-vitnode-app/root/src/routes/_admin/admin.core.index.tsx +70 -0
  47. package/copy-of-vitnode-app/root/src/routes/_admin.tsx +258 -0
  48. package/copy-of-vitnode-app/root/src/routes/_main/index.tsx +60 -0
  49. package/copy-of-vitnode-app/root/src/routes/_main.tsx +64 -0
  50. package/copy-of-vitnode-app/root/src/server/messages.server.ts +29 -0
  51. package/copy-of-vitnode-app/root/src/start.ts +88 -0
  52. package/copy-of-vitnode-app/root/src/styles.css +171 -0
  53. package/copy-of-vitnode-app/root/src/vitnode.config.ts +58 -0
  54. package/copy-of-vitnode-app/root/src/vitnode.shell.config.ts +35 -0
  55. package/copy-of-vitnode-app/root/tsconfig.json +29 -0
  56. package/copy-of-vitnode-app/root/tsr.config.json +3 -0
  57. package/copy-of-vitnode-app/root/vite.config.ts +119 -0
  58. package/copy-of-vitnode-plugin/root/.swcrc +26 -0
  59. package/copy-of-vitnode-plugin/root/global.d.ts +21 -0
  60. package/copy-of-vitnode-plugin/root/npmignore.template +17 -0
  61. package/copy-of-vitnode-plugin/root/tsconfig.build.json +5 -0
  62. package/copy-of-vitnode-plugin/root/tsconfig.json +25 -0
  63. package/dist/src/create/create-package-json.js +276 -0
  64. package/dist/src/create/create-vitnode.js +204 -0
  65. package/dist/src/create/package-versions.js +49 -0
  66. package/dist/src/helpers/get-available-package-managers.js +24 -0
  67. package/dist/src/helpers/get-package-json.js +2 -0
  68. package/dist/src/helpers/get-package-manager-from-root.js +15 -0
  69. package/dist/src/helpers/get-vitnode-package-version.js +9 -0
  70. package/dist/src/helpers/init-vitnode.js +34 -0
  71. package/dist/src/helpers/install-dependencies.js +71 -0
  72. package/dist/src/helpers/is-folder-empty.js +48 -0
  73. package/dist/src/helpers/is-online.js +38 -0
  74. package/dist/src/helpers/is-writeable.js +11 -0
  75. package/dist/src/helpers/packages-json.js +1 -0
  76. package/dist/src/helpers/validate-pkg.js +14 -0
  77. package/dist/src/helpers/with-If.js +1 -0
  78. package/dist/src/index.js +68 -0
  79. package/dist/src/plugin/create/add-plugin-to-workspace.js +74 -0
  80. package/dist/src/plugin/create/create-package-json.js +54 -0
  81. package/dist/src/plugin/create/create-plugin-vitnode.js +80 -0
  82. package/dist/src/plugin/create/route-templates.js +144 -0
  83. package/dist/src/plugin/index.js +29 -0
  84. package/dist/src/plugin/questions.js +14 -0
  85. package/dist/src/plugin/validation.js +76 -0
  86. package/dist/src/prepare/prepare.js +19 -0
  87. package/dist/src/questions.js +83 -0
  88. package/dist/src/validation.js +40 -0
  89. package/dist/tsconfig.build.tsbuildinfo +1 -0
  90. package/package.json +55 -0
@@ -0,0 +1,70 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { AdminBreadcrumb } from "@vitnode/core/tanstack/admin";
3
+ import {
4
+ AdminDashboardRouteContent,
5
+ loadAdminDashboardRoute,
6
+ } from "@vitnode/core/tanstack/admin/dashboard";
7
+
8
+ /**
9
+ * `/admin/core` - the AdminCP dashboard, and the one route file `_admin` keeps.
10
+ *
11
+ * Topology only. The layout query, the widget catalogue, the drag-and-drop
12
+ * board, the settings dialogs and the four mutations behind them are
13
+ * `@vitnode/core/tanstack/admin/dashboard`.
14
+ *
15
+ * ## Why this one file is still here
16
+ *
17
+ * Every other AdminCP screen is `@vitnode/core`'s, mounted by
18
+ * `withCoreAdminRoutes` as a code-based route - see `src/router.tsx`. This one
19
+ * stays because the file-based generator requires it to, and the requirement is
20
+ * sharp rather than stylistic:
21
+ *
22
+ * - A pathless layout with no file children is **dropped from the generated
23
+ * tree**. `buildRouteTreeConfig` skips it outright, so `_admin` would not be
24
+ * in `routeTree.gen.ts` at all and the route object `src/router.tsx` mounts
25
+ * core's screens under would be an orphan.
26
+ * - It also collapses to a full path of `/`, which collides with `_main/index.tsx`
27
+ * and fails the generator's uniqueness check by name.
28
+ *
29
+ * So `_admin` needs one file-based child with a real path in order to exist, and
30
+ * this is it - which is the same job it has always had. It was the shell's first
31
+ * child for exactly this reason, and now it is the only one.
32
+ *
33
+ * Deleting it does not remove a screen; it removes the AdminCP.
34
+ *
35
+ * ## No `head`, deliberately
36
+ *
37
+ * The Next.js page exports no `generateMetadata`, so the tab keeps the site's
38
+ * own name. Declaring `title: 'VitNode'` here would render "VitNode - VitNode"
39
+ * through `formatPageTitle`; saying nothing inherits the root's title and
40
+ * `_admin`'s `noindex`, which is the parity-preserving answer.
41
+ *
42
+ * ## `pluginWidgets` is not passed
43
+ *
44
+ * A plugin's dashboard widgets reach the Next.js board through
45
+ * `getVitNodeConfig()`, which is server-side config kept out of this app's
46
+ * browser bundle by `vitnode.shell.config.ts` - and this app registers its
47
+ * plugins by id and messages only (see `src/vitnode.config.ts`). So the board
48
+ * shows core's own widgets, which is the complete set for this install. It is
49
+ * the same seam `AdminShell` leaves open for nav `declarations`, and it changes
50
+ * here when plugin AdminCP registration moves over.
51
+ */
52
+ export const Route = createFileRoute("/_admin/admin/core/")({
53
+ loader: async ({ context }) => await loadAdminDashboardRoute(context),
54
+ component: AdminDashboardRoute,
55
+ /**
56
+ * The whole of how an admin route contributes to the trail in the shell's
57
+ * header: the route declares its own crumb next to its own component, the
58
+ * shell renders whichever matched route declared the deepest one, and there is
59
+ * no map from pathname to breadcrumb anywhere. The label comes from the
60
+ * *visible* navigation, so this reads "Core" in whatever language the
61
+ * administrator is using without this file naming a string.
62
+ */
63
+ staticData: {
64
+ breadcrumb: <AdminBreadcrumb segments={["core"]} />,
65
+ },
66
+ });
67
+
68
+ function AdminDashboardRoute() {
69
+ return <AdminDashboardRouteContent {...Route.useLoaderData()} />;
70
+ }
@@ -0,0 +1,258 @@
1
+ import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
2
+ import {
3
+ ADMIN_ENTRY_PATH,
4
+ AdminNotFound,
5
+ adminReturnToFor,
6
+ canEnterAdmin,
7
+ ensureAdminAccess,
8
+ loadAdminMessages,
9
+ preloadAdminAccess,
10
+ } from "@vitnode/core/tanstack/admin";
11
+ import { ErrorActions } from "@vitnode/core/tanstack/layout";
12
+
13
+ import { AdminShell } from "#/components/admin-shell";
14
+ import { pageHead } from "#/lib/page-head";
15
+
16
+ /**
17
+ * The boundary every AdminCP page sits under - the admin session guard, and the
18
+ * shell.
19
+ *
20
+ * Pathless: the leading underscore means it contributes no URL segment, so
21
+ * `routes/_admin/core/index.tsx` is `/admin/core`, guarded, and the guard is
22
+ * this file. A page joins the AdminCP by *where its file lives*, not by
23
+ * remembering to check a session.
24
+ *
25
+ * ## It is not under `_authenticated`, and must not be
26
+ *
27
+ * The AdminCP has its own session, under its own cookie (`vitnode_auth_admin`),
28
+ * with its own model and its own endpoint. Stacking the public guard above this
29
+ * one would bounce an administrator to `/login` for a session the AdminCP does
30
+ * not use - and `AuthState.isAdmin`, which lives on the *public* session, means
31
+ * "may be offered the AdminCP", not "is inside it". Two cookies, two questions.
32
+ *
33
+ * ## Exactly one splat may live under here, and nothing wider
34
+ *
35
+ * `admin.content.$.tsx` claims `/admin/content/*` - the Content Engine's own
36
+ * namespace, which Stage 13 moved into this router. That one is deliberate and
37
+ * narrow; a second is how the AdminCP breaks.
38
+ *
39
+ * `$.tsx`, `admin.$.tsx` or any other catch-all beside it would consume every
40
+ * *remaining* admin URL, including the ones no route declares. The AdminCP's own
41
+ * not-found would stop being reachable, a plugin's admin route would be shadowed
42
+ * by whichever splat sat above it, and nothing would report either - silently,
43
+ * and all at once. `src/tests/admin-routes.test.ts` pins both halves: that the
44
+ * content splat sits at exactly that path, and that it is the only one.
45
+ *
46
+ * ## Why the check is in `beforeLoad`
47
+ *
48
+ * It runs before the loader and long before React, so a visitor without admin
49
+ * access never receives a byte of an AdminCP page - not a flash, not a
50
+ * hydration, not a `useEffect` that redirects afterwards. A component-level
51
+ * check would render the page first and then take it away, which on the server
52
+ * means admin markup already written into the stream.
53
+ *
54
+ * ## A hover is not a navigation
55
+ *
56
+ * `defaultPreload: 'intent'` means the router runs this whole chain when a
57
+ * pointer rests on an admin link, and router-core has no staleness gate on
58
+ * `beforeLoad` the way it has on a loader - it re-runs it from the top of the
59
+ * branch every time. With `ADMIN_SESSION_STALE_TIME` at zero that made crossing
60
+ * the sidebar one server-function POST and one Hono call *per link the mouse
61
+ * passed over*, for a question nobody had asked yet.
62
+ *
63
+ * So the read is chosen by the `preload` flag the router already hands in.
64
+ * `preloadAdminAccess` is the same query, the same key and the same rejection
65
+ * behaviour, trusted for thirty seconds - the identical window the public
66
+ * session has always used for the identical reason. `ensureAdminAccess` and its
67
+ * `staleTime: 0` are untouched on the path that matters, and that is the whole
68
+ * of the revocation guarantee: entering a screen is a real navigation, this runs
69
+ * again with `preload: false`, and the API is asked. Router-core never lets a
70
+ * preloaded `beforeLoad` result stand in for a navigation's.
71
+ *
72
+ * The redirect below is deliberately *not* conditional on `preload`. It is not a
73
+ * navigation - `preloadClientRoute` catches it and preloads the sign-in page
74
+ * instead of moving anybody - and skipping it would leave a denied preload
75
+ * walking into the screen loaders underneath, which would then send admin
76
+ * requests the API is going to refuse.
77
+ *
78
+ * ## A failed read is not a denial
79
+ *
80
+ * `ensureAdminAccess` resolves only for an answer the API actually gave - `200`
81
+ * or `403`. A `429` from the rate limiter, a `500`, an API that is not listening:
82
+ * all three reject, and that rejection is deliberately left to propagate as an
83
+ * ordinary route error.
84
+ *
85
+ * Only `canEnterAdmin` answering `false` sends anybody to `/admin`. Catching the
86
+ * rejection and redirecting instead would sign every administrator out of the
87
+ * AdminCP during an outage and present them with a sign-in form for a session
88
+ * they already hold - which is precisely what the Next.js `getSessionAdminApi()`
89
+ * does today, and precisely what this shape exists to stop.
90
+ *
91
+ * ## What it is not
92
+ *
93
+ * A navigation guard, and only that. `api/config.ts` puts
94
+ * `globalAdminMiddleware()` in front of every request whose path contains
95
+ * `/admin/`, each handler re-checks the staff tables, and
96
+ * `SessionAdminModel.getUser()` re-runs `checkIfUserIsAdmin` against the
97
+ * database on every request - deleting the session the moment the answer turns
98
+ * false. So an administrator who edits this app's cached permission set in
99
+ * devtools gets a visible button and an API that still refuses them. Nothing
100
+ * here is, or may become, the security boundary.
101
+ */
102
+ export const Route = createFileRoute("/_admin")({
103
+ beforeLoad: async ({ context, location, preload }) => {
104
+ const access = preload
105
+ ? await preloadAdminAccess(context.queryClient)
106
+ : await ensureAdminAccess(context.queryClient);
107
+
108
+ if (!canEnterAdmin(access)) {
109
+ // TanStack Router's own control-flow signal: `redirect()` returns a typed
110
+ // redirect object that the router catches and turns into a navigation
111
+ // (or, during SSR, a 302). Throwing it is what stops the guard.
112
+ //
113
+ // `/admin` is a sibling leaf, not a child of this route, so this cannot
114
+ // loop: the sign-in page's own guard only redirects *away* on a granted
115
+ // session, and `sanitizeAdminReturnTo` rejects `/admin` as a target.
116
+ //
117
+ // Cast because `/admin` is not in this router's type table: it is
118
+ // `@vitnode/core`'s code-based route now, mounted by `withCoreRootRoutes`,
119
+ // and code-based routes are outside the generated tree's types. The
120
+ // *runtime* is unaffected, and `ADMIN_ENTRY_PATH` is the package's own
121
+ // constant - so the path and the sign-in route that serves it are still one
122
+ // fact in one place.
123
+ // eslint-disable-next-line @typescript-eslint/only-throw-error
124
+ throw redirect({
125
+ search: { returnTo: adminReturnToFor(location) },
126
+ to: ADMIN_ENTRY_PATH,
127
+ } as unknown as Parameters<typeof redirect>[0]);
128
+ }
129
+
130
+ // Merged into the context of everything below, so a child route reads
131
+ // `context.adminAccess` already narrowed to the granted half of the union.
132
+ // It is the same object the guard decided on, from the same cache entry, so
133
+ // a page cannot disagree with the guard that let it render.
134
+ return { adminAccess: access };
135
+ },
136
+ /**
137
+ * The shell's strings, warmed before React renders.
138
+ *
139
+ * `adminNav.namespaces` is the same array `AdminShell` hands the shell, and it
140
+ * has to be: the provider reads back the identical `intlQueryOptions` entry
141
+ * this fills, so warming a different namespace set would fill an entry nobody
142
+ * looks at and cost a round trip on the first paint anyway. It is not a fixed
143
+ * list because it cannot be - a plugin group's headings live under that
144
+ * plugin's own id, and which plugins this installation configured is decided
145
+ * in `src/admin-nav.gen.ts`.
146
+ *
147
+ * ## Imported inside the loader, not above it
148
+ *
149
+ * A route file's `loader` body runs only for a navigation into this shell; the
150
+ * file itself is evaluated in the client entry, on every page of the site. The
151
+ * generated navigation is not small - it carries every configured plugin's
152
+ * sidebar icons and its content type definitions, and through those the whole
153
+ * Content Engine and `zod` - so a static import here put ~45 KB of AdminCP
154
+ * data, plus `zod`, in front of the front page's first paint. `AdminShell`
155
+ * imports the same module, and `AdminShell` is this route's `component`, which
156
+ * is code-split: the two land in the same chunk, so an administrator pays for
157
+ * it once and everybody else not at all.
158
+ */
159
+ loader: async ({ context }) => {
160
+ const { adminNav } = await import("#/lib/admin-nav");
161
+
162
+ await loadAdminMessages({ ...context, namespaces: adminNav.namespaces });
163
+ },
164
+ /**
165
+ * Stated once for the whole panel rather than on each screen.
166
+ *
167
+ * The AdminCP is behind a session and must never be indexed. Router merges the
168
+ * `head` of every matched route and dedupes `meta` by `name`, preferring the
169
+ * deepest, so an admin screen inherits this by saying nothing - which is
170
+ * exactly what `RouteHeadOptions` describes. A screen's own `pageHead` adds
171
+ * only its title and description.
172
+ */
173
+ head: () => pageHead({ robots: "noindex, nofollow" }),
174
+ /**
175
+ * The AdminCP's 404, rendered inside the shell.
176
+ *
177
+ * What reaches it is a screen whose loader called `requireAdminPermission`
178
+ * and was refused - the same answer `app/[locale]/admin/(auth)/not-found.tsx`
179
+ * gives in the Next.js AdminCP. Declared here rather than per screen so every
180
+ * one of them answers a missing permission identically.
181
+ *
182
+ * A content URL that resolves to no content type reaches it too, and by the
183
+ * same route: `loadContentAdminRoute` throws `notFound()` for a splat its
184
+ * registry cannot name, which is the answer the Next.js catch-all's
185
+ * `notFound()` gives one navigation later.
186
+ *
187
+ * A URL under `/admin` that matches no route at all does *not* reach this one.
188
+ * Outside `/admin/content`, `_admin` has only declared children - see above -
189
+ * so such a path matches nothing in this subtree and the router falls back to
190
+ * its own not-found at the root. That is the correct trade, and the cost is
191
+ * paid deliberately: rendering an unmigrated admin URL inside this shell would
192
+ * mean claiming it. (The root has no `notFoundComponent` of its own yet, so
193
+ * that fallback is currently the router's bare one - for every unmatched URL
194
+ * in this application, not only admin ones. Giving the root a real 404 page is
195
+ * its own piece of work.)
196
+ */
197
+ notFoundComponent: AdminNotFoundScreen,
198
+ component: AdminLayout,
199
+ });
200
+
201
+ /**
202
+ * The refusal, wearing the panel it was refused inside.
203
+ *
204
+ * ## It mounts the shell itself, and has to
205
+ *
206
+ * A `notFoundComponent` renders *instead of* the component of the route that
207
+ * handles the error, not inside it - so this replaces `AdminLayout`, sidebar
208
+ * and header and palette included. An administrator who opened a screen they
209
+ * lack the permission for would otherwise be dropped onto a bare 404 page with
210
+ * no way back into the AdminCP but the browser's back button, which is not what
211
+ * the Next.js AdminCP does: its `not-found.tsx` sits *under*
212
+ * `admin/(auth)/layout.tsx` and keeps the panel around the message.
213
+ *
214
+ * Mounting `AdminShell` here restores that, and it costs nothing: `beforeLoad`
215
+ * has already resolved the admin session and the loader above has already
216
+ * warmed the shell's messages, so the providers inside read the same two cache
217
+ * entries the working screens read and nothing suspends or fetches again.
218
+ *
219
+ * ## What is bound rather than defaulted
220
+ *
221
+ * `ErrorActions` is this app's binding rather than core's default: `/` is served
222
+ * by the Next.js application on some installs and by this one on others, and
223
+ * only the route tree knows which. It is passed as an element from module scope
224
+ * so the type is stable across renders.
225
+ */
226
+ function AdminNotFoundScreen() {
227
+ return (
228
+ <AdminShell>
229
+ <AdminNotFound actions={<ErrorActions />} />
230
+ </AdminShell>
231
+ );
232
+ }
233
+
234
+ /**
235
+ * The AdminCP shell.
236
+ *
237
+ * `AdminShell` is this app's binding of `AdminShellContent` - the sidebar, the
238
+ * command palette, the breadcrumb area, the user menu and the one `<main>` every
239
+ * admin page renders inside. It mounts `AdminPermissionsProvider` itself, from
240
+ * the same admin session query the guard above has already filled, so
241
+ * `AdminStaffPermissionGate` and `useAdminStaffPermission` work identically here
242
+ * and in the Next.js AdminCP. It cannot suspend in practice and nothing below it
243
+ * can suspend at all; see the note on the provider.
244
+ *
245
+ * What `#/components/admin-shell` adds on top is only what a package cannot
246
+ * answer for a particular installation: where the user lookup runs, which
247
+ * plugins this app configured, and how the command palette moves without a link.
248
+ *
249
+ * The guard, the loader and the route options above are the parts that must not
250
+ * move.
251
+ */
252
+ function AdminLayout() {
253
+ return (
254
+ <AdminShell>
255
+ <Outlet />
256
+ </AdminShell>
257
+ );
258
+ }
@@ -0,0 +1,60 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+
3
+ import { pageHead } from "#/lib/page-head";
4
+
5
+ /**
6
+ * The front page.
7
+ *
8
+ * One route file serving every public URL of the home page: `/` and `/pl` both
9
+ * match here, because the locale is stripped before matching and written back
10
+ * into every link the router builds. There is no `routes/pl/index.tsx` and there
11
+ * does not need to be one.
12
+ *
13
+ * **`head` must be written after `loader`** on routes that have one -
14
+ * `loaderData` is inferred from `loader` in the same object literal, and
15
+ * TypeScript reads a literal's members in order. This route has no loader: it
16
+ * fetches nothing, and the shell above it already warmed everything the header
17
+ * reads.
18
+ *
19
+ * `robots: 'index, follow'` is stated rather than assumed. `_main` says nothing
20
+ * about indexing, and a page that wants to be found should say so where somebody
21
+ * editing it will see it.
22
+ */
23
+ export const Route = createFileRoute("/_main/")({
24
+ head: () =>
25
+ pageHead({
26
+ description: "A VitNode application.",
27
+ robots: "index, follow",
28
+ title: "Home",
29
+ }),
30
+ component: HomeRoute,
31
+ });
32
+
33
+ /**
34
+ * Replace this with your own home page.
35
+ *
36
+ * No `<main>`: the shell owns the document's one `main` landmark, and a page
37
+ * that renders a second gives a screen reader two to choose between. A page owns
38
+ * its container - width, padding, vertical rhythm - and nothing above it.
39
+ *
40
+ * The copy is hard-coded because a new app has no strings of its own yet. To
41
+ * translate it, add the keys to `src/locales/` and read them with
42
+ * `useTranslations` from `use-intl` - the same hook every VitNode component
43
+ * uses, on every host.
44
+ */
45
+ function HomeRoute() {
46
+ return (
47
+ <div className="container mx-auto flex max-w-2xl flex-col gap-4 p-4">
48
+ <h1 className="text-3xl font-semibold tracking-tight text-balance">
49
+ Welcome to VitNode
50
+ </h1>
51
+
52
+ <p className="text-muted-foreground leading-relaxed text-pretty">
53
+ Edit <code>src/routes/_main/index.tsx</code> to change this page. The
54
+ header, the footer and the shell around it are{" "}
55
+ <code>@vitnode/core</code>&rsquo;s; everything under{" "}
56
+ <code>src/routes</code> is yours.
57
+ </p>
58
+ </div>
59
+ );
60
+ }
@@ -0,0 +1,64 @@
1
+ import { createFileRoute, Outlet } from "@tanstack/react-router";
2
+ import { MainBreadcrumb } from "@vitnode/core/tanstack/breadcrumb";
3
+ import {
4
+ loadMainShell,
5
+ ThemeLayoutContent,
6
+ } from "@vitnode/core/tanstack/layout";
7
+
8
+ import { MainHeader } from "#/components/main-header";
9
+
10
+ /**
11
+ * The main application shell - the header, the breadcrumb area and the one
12
+ * `<main>` landmark that every public page renders inside.
13
+ *
14
+ * Pathless, so it contributes no URL segment: `/discover` is `/discover`, not
15
+ * `/_main/discover`. A page joins the shell by *where its file lives*, which is
16
+ * the same rule `_authenticated` uses for the session guard - and the reason
17
+ * `_authenticated` lives underneath this one: a signed-in page is still a page
18
+ * on the public site, so it wants the shell *and* the guard rather than a second
19
+ * copy of the shell.
20
+ *
21
+ * ## What is deliberately outside it
22
+ *
23
+ * The four auth screens: `/login`, `/login/sso/$providerId`, `/register` and
24
+ * `/login/reset-password`. An auth screen is a full-height card on an otherwise
25
+ * empty document, and the header it would render is a header whose only
26
+ * interesting control is "sign in". Keeping them out is what makes this a shell
27
+ * that routes opt into rather than one every route is subject to.
28
+ * `routes/api/$` is outside for a different reason: it is a server route and
29
+ * renders no document at all. Note this is a visual difference from the Next.js
30
+ * app, where all four sit inside `(main)` and do render the header.
31
+ *
32
+ * `src/tests/main-shell.test.ts` asserts both halves - the settings paths
33
+ * inside, the four auth screens outside.
34
+ *
35
+ * ## The slots
36
+ *
37
+ * `ThemeLayoutContent`'s, and two of the same three the Next.js `ThemeLayout`
38
+ * fills: `header` and `breadcrumb`.
39
+ *
40
+ * `listeners` is deliberately left empty here. The Next.js app puts the
41
+ * notification toasts and the WebSocket's sign-in resync in it because its
42
+ * `/login` is inside the main shell; this app's is not, so a sync mounted here
43
+ * would not exist during the sign-in it has to notice. They are mounted by
44
+ * `__root` instead, next to the connection whose lifetime they share.
45
+ *
46
+ * ## What it is not
47
+ *
48
+ * A provider. Every technical provider this app has - the QueryClient, the two
49
+ * intl records, the theme, the WebSocket - is mounted once by `__root`, above
50
+ * every route, because a login screen needs them just as much as a page under
51
+ * this shell does. What lives here is structure: markup, and where the slots go.
52
+ */
53
+ export const Route = createFileRoute("/_main")({
54
+ loader: async ({ context }) => await loadMainShell(context),
55
+ component: MainLayout,
56
+ });
57
+
58
+ function MainLayout() {
59
+ return (
60
+ <ThemeLayoutContent breadcrumb={<MainBreadcrumb />} header={<MainHeader />}>
61
+ <Outlet />
62
+ </ThemeLayoutContent>
63
+ );
64
+ }
@@ -0,0 +1,29 @@
1
+ import "@tanstack/react-start/server-only";
2
+ import { createIntlMessagesLoader } from "@vitnode/core/tanstack/i18n/server";
3
+
4
+ import { packageMessages } from "#/locales/packages";
5
+ import { vitNodeConfig } from "#/vitnode.config";
6
+
7
+ export type { IntlMessages } from "@vitnode/core/tanstack/i18n/server";
8
+
9
+ /**
10
+ * The messages one page needs, in one language - this app's loader.
11
+ *
12
+ * Everything about *how* messages load is core's: the source order (core, then
13
+ * each plugin, then this app's overrides, later ones winning), the `web` scope
14
+ * stamped on each so the API's tree in this same process cannot be served in its
15
+ * place, the per-key fallback to the default locale, and the namespace pick that
16
+ * keeps every plugin's AdminCP copy out of a page that renders none of it.
17
+ *
18
+ * What is this app's, and can only be: the plugins it registered, the languages
19
+ * it declares, and `packageMessages` - the static loaders that stand in for each
20
+ * package's own locale barrel, because a barrel's
21
+ * `import("./en.json", { with: { type: "json" } })` is a specifier Rollup will
22
+ * not follow. See `src/locales/packages.ts` for the reproduction.
23
+ */
24
+ export const loadIntlMessages = createIntlMessagesLoader({
25
+ appMessages: vitNodeConfig.i18n.messages,
26
+ defaultLocale: vitNodeConfig.i18n.defaultLocale,
27
+ packageMessages,
28
+ plugins: vitNodeConfig.plugins,
29
+ });
@@ -0,0 +1,88 @@
1
+ import {
2
+ createCsrfMiddleware,
3
+ createMiddleware,
4
+ createStart,
5
+ } from "@tanstack/react-start";
6
+ import { handleLocaleRequest } from "@vitnode/core/tanstack/i18n/server";
7
+
8
+ import {
9
+ applyDocumentCacheControl,
10
+ applyRedirectCacheControl,
11
+ } from "#/lib/document-headers";
12
+ import { localeRouting } from "#/lib/i18n/runtime";
13
+
14
+ /**
15
+ * Locale routing, as the first thing that happens to a request.
16
+ *
17
+ * A global request middleware rather than a wrapper around the server entry:
18
+ * Start runs these before route matching and before SSR, which is exactly where
19
+ * a canonical redirect belongs - the alternative is rendering a page and then
20
+ * throwing it away.
21
+ *
22
+ * `handlerType` narrows it to page requests. Server function calls arrive on
23
+ * `/_serverFn/*` with `handlerType: "serverFn"`, and redirecting an RPC to a
24
+ * canonical URL would break it rather than tidy it.
25
+ *
26
+ * `/api/*` reaches here too and is deliberately ignored by
27
+ * `handleLocaleRequest`, so the Stage 1 Hono bridge sees the request exactly as
28
+ * the client sent it.
29
+ *
30
+ * `localeRouting` is handed in rather than read from the package's registered
31
+ * runtime: Start runs request middleware before route matching, so this is the
32
+ * one caller that cannot assume the router entry has been evaluated. Importing
33
+ * it from `#/lib/i18n/runtime` is also what guarantees this app's i18n is
34
+ * configured before the first request touches it.
35
+ *
36
+ * ## And what a document is allowed to say about itself
37
+ *
38
+ * The cache directive rides along here for one reason: this is already the only
39
+ * place in the application that holds every page response, before and after
40
+ * rendering, and a second middleware would be a second thing to remember. Every
41
+ * document this app produces carries a dehydrated Query cache containing the
42
+ * visitor's own session - and, under `/admin`, an administrator's whole
43
+ * permission set - so none of them may be stored by a shared cache. See
44
+ * `#/lib/document-headers`, which owns the rule and says why; this file only
45
+ * applies it.
46
+ */
47
+ const localeMiddleware = createMiddleware().server(
48
+ async ({ handlerType, next, request }) => {
49
+ if (handlerType !== "router") return await next();
50
+
51
+ const { redirect, setCookie } = handleLocaleRequest(request, localeRouting);
52
+ if (redirect) {
53
+ applyRedirectCacheControl(redirect);
54
+
55
+ return redirect;
56
+ }
57
+
58
+ const result = await next();
59
+
60
+ // `append`, not `set`: the API mounted at `/api/*` and the auth flow both
61
+ // mint their own cookies, and overwriting the header would sign people out.
62
+ if (setCookie) result.response.headers.append("set-cookie", setCookie);
63
+
64
+ // After the cookie, so a document that just wrote one is covered by the
65
+ // same directive as one that did not. Only an HTML response is touched -
66
+ // `/api/*` reaches here too, and the API's own caching is not this
67
+ // middleware's to decide.
68
+ applyDocumentCacheControl(result.response);
69
+
70
+ return result;
71
+ },
72
+ );
73
+
74
+ /**
75
+ * This app's Start instance.
76
+ *
77
+ * `createCsrfMiddleware` is not optional here. Start installs it *only* while an
78
+ * app declares no `requestMiddleware` of its own - the moment this file exists,
79
+ * the default is replaced by whatever it lists, and leaving CSRF out would
80
+ * expose every server function as an unauthenticated cross-site endpoint. It is
81
+ * declared first so it runs before anything else.
82
+ */
83
+ export const startInstance = createStart(() => ({
84
+ requestMiddleware: [
85
+ createCsrfMiddleware({ filter: ctx => ctx.handlerType === "serverFn" }),
86
+ localeMiddleware,
87
+ ],
88
+ }));