create-vitnode-app 1.2.0-canary.9 → 2.0.0-canary.2

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 (107) hide show
  1. package/LICENSE.md +1 -1
  2. package/README.md +58 -18
  3. package/copy-of-vitnode-app/.vscode/settings.json +10 -0
  4. package/copy-of-vitnode-app/README.md +62 -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/index.ts +27 -0
  8. package/copy-of-vitnode-app/api/src/vitnode.api.config.ts +57 -0
  9. package/copy-of-vitnode-app/api/tsconfig.json +17 -0
  10. package/copy-of-vitnode-app/api-bun/src/index.ts +16 -0
  11. package/copy-of-vitnode-app/api-single-app/drizzle.config.ts +12 -0
  12. package/copy-of-vitnode-app/api-single-app/src/routes/api/$.ts +32 -0
  13. package/copy-of-vitnode-app/api-single-app/src/server/api-bridge.ts +22 -0
  14. package/copy-of-vitnode-app/api-single-app/src/server/vitnode-api.server.ts +29 -0
  15. package/copy-of-vitnode-app/api-single-app/src/vitnode.api.config.ts +36 -0
  16. package/copy-of-vitnode-app/docker/docker-compose.yml +30 -0
  17. package/copy-of-vitnode-app/eslint/.prettierrc.mjs +1 -1
  18. package/copy-of-vitnode-app/eslint/eslint.config.mjs +18 -2
  19. package/copy-of-vitnode-app/{root → eslint-react}/.prettierrc.mjs +1 -1
  20. package/copy-of-vitnode-app/eslint-react/eslint.config.mjs +19 -0
  21. package/copy-of-vitnode-app/monorepo/.gitignore_template +41 -0
  22. package/copy-of-vitnode-app/monorepo/apps/api/.env.example +12 -0
  23. package/copy-of-vitnode-app/monorepo/apps/web/.env.example +12 -0
  24. package/copy-of-vitnode-app/monorepo/turbo.json +53 -0
  25. package/copy-of-vitnode-app/root/.env.example +16 -5
  26. package/copy-of-vitnode-app/root/.gitignore_template +41 -0
  27. package/copy-of-vitnode-app/root/global.d.ts +28 -0
  28. package/copy-of-vitnode-app/root/src/components/admin-shell.tsx +72 -0
  29. package/copy-of-vitnode-app/root/src/components/main-header.tsx +33 -0
  30. package/copy-of-vitnode-app/root/src/lib/admin-auth.ts +11 -0
  31. package/copy-of-vitnode-app/root/src/lib/admin-nav.ts +43 -0
  32. package/copy-of-vitnode-app/root/src/lib/admin-search.ts +37 -0
  33. package/copy-of-vitnode-app/root/src/lib/auth.ts +66 -0
  34. package/copy-of-vitnode-app/root/src/lib/content-registry.ts +52 -0
  35. package/copy-of-vitnode-app/root/src/lib/i18n/runtime.ts +75 -0
  36. package/copy-of-vitnode-app/root/src/lib/i18n/shared.ts +20 -0
  37. package/copy-of-vitnode-app/root/src/lib/navigation.ts +24 -0
  38. package/copy-of-vitnode-app/root/src/lib/page-head.ts +19 -0
  39. package/copy-of-vitnode-app/root/src/locales/app.ts +28 -0
  40. package/copy-of-vitnode-app/root/src/locales/packages.ts +43 -0
  41. package/copy-of-vitnode-app/root/src/router.tsx +227 -0
  42. package/copy-of-vitnode-app/root/src/routes/__root.tsx +212 -0
  43. package/copy-of-vitnode-app/root/src/routes/_admin/admin.core.index.tsx +69 -0
  44. package/copy-of-vitnode-app/root/src/routes/_admin.tsx +258 -0
  45. package/copy-of-vitnode-app/root/src/routes/_main/index.tsx +60 -0
  46. package/copy-of-vitnode-app/root/src/routes/_main.tsx +64 -0
  47. package/copy-of-vitnode-app/root/src/server/messages.server.ts +21 -0
  48. package/copy-of-vitnode-app/root/src/start.ts +24 -0
  49. package/copy-of-vitnode-app/root/src/styles.css +139 -0
  50. package/copy-of-vitnode-app/root/src/vitnode.config.ts +73 -0
  51. package/copy-of-vitnode-app/root/src/vitnode.server.config.ts +23 -0
  52. package/copy-of-vitnode-app/root/tsconfig.json +24 -16
  53. package/copy-of-vitnode-app/root/tsr.config.json +3 -0
  54. package/copy-of-vitnode-app/root/vite.config.ts +106 -0
  55. package/copy-of-vitnode-plugin/root/.swcrc +26 -0
  56. package/copy-of-vitnode-plugin/root/global.d.ts +21 -0
  57. package/copy-of-vitnode-plugin/root/npmignore.template +17 -0
  58. package/copy-of-vitnode-plugin/root/tsconfig.build.json +5 -0
  59. package/copy-of-vitnode-plugin/root/tsconfig.json +25 -0
  60. package/dist/src/create/create-package-json.js +266 -56
  61. package/dist/src/create/create-vitnode.js +177 -28
  62. package/dist/src/create/package-versions.js +49 -0
  63. package/dist/src/helpers/get-available-package-managers.js +6 -6
  64. package/dist/src/helpers/get-package-json.js +2 -2
  65. package/dist/src/helpers/get-package-manager-from-root.js +15 -0
  66. package/dist/src/helpers/get-vitnode-package-version.js +9 -0
  67. package/dist/src/helpers/init-vitnode.js +34 -0
  68. package/dist/src/helpers/install-dependencies.js +55 -14
  69. package/dist/src/helpers/is-folder-empty.js +25 -26
  70. package/dist/src/helpers/is-online.js +16 -16
  71. package/dist/src/helpers/is-writeable.js +3 -3
  72. package/dist/src/helpers/validate-pkg.js +1 -1
  73. package/dist/src/helpers/with-If.js +1 -0
  74. package/dist/src/index.js +29 -26
  75. package/dist/src/plugin/create/add-plugin-to-workspace.js +74 -0
  76. package/dist/src/plugin/create/create-package-json.js +54 -0
  77. package/dist/src/plugin/create/create-plugin-vitnode.js +80 -0
  78. package/dist/src/plugin/create/route-templates.js +144 -0
  79. package/dist/src/plugin/index.js +17 -6
  80. package/dist/src/plugin/questions.js +14 -0
  81. package/dist/src/plugin/validation.js +76 -0
  82. package/dist/src/prepare/prepare.js +11 -8
  83. package/dist/src/questions.js +52 -15
  84. package/dist/src/validation.js +15 -15
  85. package/dist/tsconfig.build.tsbuildinfo +1 -0
  86. package/package.json +22 -12
  87. package/copy-of-vitnode-app/root/README.md +0 -1
  88. package/copy-of-vitnode-app/root/drizzle.config.ts +0 -12
  89. package/copy-of-vitnode-app/root/gitignore_template +0 -43
  90. package/copy-of-vitnode-app/root/next.config.ts +0 -11
  91. package/copy-of-vitnode-app/root/postcss.config.mjs +0 -8
  92. package/copy-of-vitnode-app/root/src/app/[locale]/(main)/[...rest]/page.tsx +0 -5
  93. package/copy-of-vitnode-app/root/src/app/[locale]/(main)/layout.tsx +0 -10
  94. package/copy-of-vitnode-app/root/src/app/[locale]/(main)/not-found.tsx +0 -5
  95. package/copy-of-vitnode-app/root/src/app/[locale]/(main)/page.tsx +0 -113
  96. package/copy-of-vitnode-app/root/src/app/[locale]/admin/(auth)/layout.tsx +0 -10
  97. package/copy-of-vitnode-app/root/src/app/[locale]/admin/not-found.tsx +0 -5
  98. package/copy-of-vitnode-app/root/src/app/[locale]/admin/page.tsx +0 -5
  99. package/copy-of-vitnode-app/root/src/app/[locale]/layout.tsx +0 -36
  100. package/copy-of-vitnode-app/root/src/app/api/[...route]/route.ts +0 -18
  101. package/copy-of-vitnode-app/root/src/app/favicon.ico +0 -0
  102. package/copy-of-vitnode-app/root/src/app/global-error.tsx +0 -27
  103. package/copy-of-vitnode-app/root/src/app/global.css +0 -128
  104. package/copy-of-vitnode-app/root/src/app/layout.tsx +0 -9
  105. package/copy-of-vitnode-app/root/src/app/not-found.tsx +0 -13
  106. package/copy-of-vitnode-app/root/src/vitnode.api.config.ts +0 -5
  107. package/dist/tsconfig.tsbuildinfo +0 -1
@@ -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,21 @@
1
+ import "@tanstack/react-start/server-only";
2
+ import { createIntlMessagesLoader } from "@vitnode/core/tanstack/i18n/server";
3
+
4
+ import { vitNodeServerConfig } from "#/vitnode.server.config";
5
+
6
+ export type { IntlMessages } from "@vitnode/core/tanstack/i18n/server";
7
+
8
+ /**
9
+ * The messages one page needs, in one language - this app's loader.
10
+ *
11
+ * Everything about *how* messages load is core's: the source order (core, then
12
+ * each plugin, then this app's overrides, later ones winning), the `web` scope
13
+ * stamped on each so the API's tree in this same process cannot be served in its
14
+ * place, the per-key fallback to the default locale, and the namespace pick that
15
+ * keeps every plugin's AdminCP copy out of a page that renders none of it.
16
+ *
17
+ * What is this app's is the server config it is handed - the plugins it
18
+ * registered, the languages it declares, and the loaders that stand in for each
19
+ * package's own locale barrel.
20
+ */
21
+ export const loadIntlMessages = createIntlMessagesLoader(vitNodeServerConfig);
@@ -0,0 +1,24 @@
1
+ import { createVitNodeStart } from "@vitnode/core/tanstack/start";
2
+
3
+ import { vitNodeConfig } from "#/vitnode.config";
4
+
5
+ /**
6
+ * This app's Start instance.
7
+ *
8
+ * The whole request pipeline is VitNode's, and it is mandatory rather than a
9
+ * default: CSRF protection on server functions, canonical locale redirects with
10
+ * the remembered-locale cookie, and the `private, no-store` directive every
11
+ * document carrying a dehydrated session needs.
12
+ *
13
+ * Add this app's own request middleware - a request id, a tracing span, a
14
+ * maintenance gate - with `requestMiddleware`. It runs after all of the above,
15
+ * which is the only safe place for it: a redirect ends the request, so anything
16
+ * in front of the locale rule would run twice for every visitor arriving at a
17
+ * non-canonical URL.
18
+ *
19
+ * export const startInstance = createVitNodeStart({
20
+ * config: vitNodeConfig,
21
+ * requestMiddleware: [myMiddleware],
22
+ * })
23
+ */
24
+ export const startInstance = createVitNodeStart({ config: vitNodeConfig });
@@ -0,0 +1,139 @@
1
+ @import "tailwindcss";
2
+ @import "shadcn/tailwind.css";
3
+ @import "tw-animate-css";
4
+
5
+ @source "../node_modules/@vitnode/core/dist/src/components";
6
+ @source "../node_modules/@vitnode/core/dist/src/tanstack";
7
+ @source "../node_modules/@vitnode/core/dist/src/views";
8
+
9
+ :root:not(.dark) {
10
+ --background: oklch(0.97 0 0);
11
+ --foreground: oklch(0.145 0 0);
12
+ --card: oklch(1 0 0);
13
+ --card-foreground: oklch(0.145 0 0);
14
+ --popover: oklch(1 0 0);
15
+ --popover-foreground: oklch(0.145 0 0);
16
+ --primary: oklch(0.51 0.16 262.61);
17
+ --primary-foreground: oklch(0.985 0 0);
18
+ --secondary: oklch(0.967 0.001 286.375);
19
+ --secondary-foreground: oklch(0.21 0.006 285.885);
20
+ --muted: oklch(0.967 0.001 286.375);
21
+ --muted-foreground: oklch(0.552 0.016 285.938);
22
+ --accent: oklch(0.967 0.001 286.375);
23
+ --accent-foreground: oklch(0.21 0.006 285.885);
24
+ --destructive: oklch(0.577 0.245 27.325);
25
+ --warn: oklch(0.54 0.12 82.58);
26
+ --border: oklch(0.92 0.004 286.32);
27
+ --input: oklch(0.92 0.004 286.32);
28
+ --ring: oklch(0.705 0.015 286.067);
29
+ --chart-1: oklch(0.871 0.006 286.286);
30
+ --chart-2: oklch(0.552 0.016 285.938);
31
+ --chart-3: oklch(0.442 0.017 285.786);
32
+ --chart-4: oklch(0.37 0.013 285.805);
33
+ --chart-5: oklch(0.274 0.006 286.033);
34
+ --sidebar: oklch(0.985 0 0);
35
+ --sidebar-foreground: oklch(0.141 0.005 285.823);
36
+ --sidebar-primary: oklch(0.21 0.006 285.885);
37
+ --sidebar-primary-foreground: oklch(0.985 0 0);
38
+ --sidebar-accent: oklch(0.967 0.001 286.375);
39
+ --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
40
+ --sidebar-border: oklch(0.92 0.004 286.32);
41
+ --sidebar-ring: oklch(0.705 0.015 286.067);
42
+ }
43
+
44
+ .dark {
45
+ --background: oklch(0.14 0 0);
46
+ --foreground: oklch(0.985 0 0);
47
+ --card: oklch(0.21 0.006 285.885);
48
+ --card-foreground: oklch(0.985 0 0);
49
+ --popover: oklch(0.21 0.006 285.885);
50
+ --popover-foreground: oklch(0.985 0 0);
51
+ --primary: oklch(0.6 0.18 262.65);
52
+ --primary-foreground: oklch(0.98 0 0);
53
+ --secondary: oklch(0.274 0.006 286.033);
54
+ --secondary-foreground: oklch(0.985 0 0);
55
+ --muted: oklch(0.274 0.006 286.033);
56
+ --muted-foreground: oklch(0.705 0.015 286.067);
57
+ --accent: oklch(0.274 0.006 286.033);
58
+ --accent-foreground: oklch(0.985 0 0);
59
+ --destructive: oklch(0.704 0.191 22.216);
60
+ --warn: oklch(0.76 0.18 81.84);
61
+ --border: oklch(1 0 0 / 10%);
62
+ --input: oklch(1 0 0 / 15%);
63
+ --ring: oklch(0.552 0.016 285.938);
64
+ --chart-1: oklch(0.871 0.006 286.286);
65
+ --chart-2: oklch(0.552 0.016 285.938);
66
+ --chart-3: oklch(0.442 0.017 285.786);
67
+ --chart-4: oklch(0.37 0.013 285.805);
68
+ --chart-5: oklch(0.274 0.006 286.033);
69
+ --sidebar: oklch(0.21 0.006 285.885);
70
+ --sidebar-foreground: oklch(0.985 0 0);
71
+ --sidebar-primary: oklch(0.488 0.243 264.376);
72
+ --sidebar-primary-foreground: oklch(0.985 0 0);
73
+ --sidebar-accent: oklch(0.274 0.006 286.033);
74
+ --sidebar-accent-foreground: oklch(0.985 0 0);
75
+ --sidebar-border: oklch(1 0 0 / 10%);
76
+ --sidebar-ring: oklch(0.552 0.016 285.938);
77
+ }
78
+
79
+ :root {
80
+ --radius: 0.625rem;
81
+ }
82
+
83
+ @theme inline {
84
+ --font-heading: var(--font-sans);
85
+ --color-sidebar-ring: var(--sidebar-ring);
86
+ --color-sidebar-border: var(--sidebar-border);
87
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
88
+ --color-sidebar-accent: var(--sidebar-accent);
89
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
90
+ --color-sidebar-primary: var(--sidebar-primary);
91
+ --color-sidebar-foreground: var(--sidebar-foreground);
92
+ --color-sidebar: var(--sidebar);
93
+ --color-chart-5: var(--chart-5);
94
+ --color-chart-4: var(--chart-4);
95
+ --color-chart-3: var(--chart-3);
96
+ --color-chart-2: var(--chart-2);
97
+ --color-chart-1: var(--chart-1);
98
+ --color-ring: var(--ring);
99
+ --color-input: var(--input);
100
+ --color-border: var(--border);
101
+ --color-warn: var(--warn);
102
+ --color-destructive: var(--destructive);
103
+ --color-accent-foreground: var(--accent-foreground);
104
+ --color-accent: var(--accent);
105
+ --color-muted-foreground: var(--muted-foreground);
106
+ --color-muted: var(--muted);
107
+ --color-secondary-foreground: var(--secondary-foreground);
108
+ --color-secondary: var(--secondary);
109
+ --color-primary-foreground: var(--primary-foreground);
110
+ --color-primary: var(--primary);
111
+ --color-popover-foreground: var(--popover-foreground);
112
+ --color-popover: var(--popover);
113
+ --color-card-foreground: var(--card-foreground);
114
+ --color-card: var(--card);
115
+ --color-foreground: var(--foreground);
116
+ --color-background: var(--background);
117
+ --radius-sm: calc(var(--radius) * 0.6);
118
+ --radius-md: calc(var(--radius) * 0.8);
119
+ --radius-lg: var(--radius);
120
+ --radius-xl: calc(var(--radius) * 1.4);
121
+ --radius-2xl: calc(var(--radius) * 1.8);
122
+ --radius-3xl: calc(var(--radius) * 2.2);
123
+ --radius-4xl: calc(var(--radius) * 2.6);
124
+ }
125
+
126
+ @layer base {
127
+ * {
128
+ @apply border-border outline-ring/50;
129
+ }
130
+
131
+ html,
132
+ body {
133
+ min-height: 100%;
134
+ }
135
+
136
+ body {
137
+ @apply bg-background text-foreground antialiased;
138
+ }
139
+ }
@@ -0,0 +1,73 @@
1
+ import { buildConfig } from "@vitnode/core/vitnode.config";
2
+
3
+ /**
4
+ * This app's configuration - the one file every VitNode app edits first.
5
+ *
6
+ * **Browser-safe, and everything here has to stay that way.** Three very
7
+ * different readers depend on it: `routes/__root.tsx` renders the document shell
8
+ * from `metadata`, `theme` and `debug`; `lib/i18n/runtime.ts` derives the locale
9
+ * routing from `i18n`; and Vite's own plugin registry loads this file with
10
+ * `jiti` while it is still resolving its config, to find out which plugins to
11
+ * generate route, navigation and content-registry imports for. So it is plain
12
+ * data and plugin *identity* - never a `() => import(...)` message loader, never
13
+ * a module that reaches a database. Anything like that goes in
14
+ * `vitnode.server.config.ts`.
15
+ *
16
+ * ## Adding a language
17
+ *
18
+ * Add an entry to `locales`. Packages ship their own translations, so a new
19
+ * locale needs nothing else: anything a package has not translated falls back to
20
+ * `defaultLocale` key by key. Register the package's file for that language in
21
+ * `src/locales/packages.ts`, and put your own rewording in
22
+ * `src/locales/app.ts` - both of which `vitnode.server.config.ts` picks up.
23
+ *
24
+ * `vitnode i18n:create de Deutsch` does all of it for you.
25
+ *
26
+ * ## Adding a plugin
27
+ *
28
+ * With the plugin's own factory:
29
+ *
30
+ * import { blogPlugin } from '@acme/blog/config'
31
+ *
32
+ * plugins: [blogPlugin()]
33
+ *
34
+ * That is the whole registration - the factory carries the plugin's content
35
+ * types, its AdminCP navigation and its translations. Register its locale files
36
+ * in `src/locales/packages.ts` as well, which is what the message loader
37
+ * actually reads.
38
+ *
39
+ * What the AdminCP renders comes back through `src/admin-nav.gen.ts` and
40
+ * `src/content-registry.gen.ts` rather than out of this object: the build writes
41
+ * one literal import per configured plugin, and `src/router.tsx` loads the
42
+ * content registry behind a dynamic `import()`, so a content type's editing
43
+ * screen arrives with the route that renders it.
44
+ *
45
+ * A plugin's *pages* need nothing in this file at all. It declares them in its
46
+ * own `src/routes.ts`, and this app's Vite build compiles them into
47
+ * `src/plugin-routes.gen.ts`, which `src/router.tsx` mounts under the shell the
48
+ * plugin's `area` names. No page is ever copied into `src/routes`.
49
+ *
50
+ * `buildConfig` also registers this object process-wide, which is how core's own
51
+ * route files find it without being handed it as a prop.
52
+ */
53
+ export const vitNodeConfig = buildConfig({
54
+ debug: false,
55
+ i18n: {
56
+ defaultLocale: "en",
57
+ locales: [{ code: "en", name: "English" }],
58
+ /**
59
+ * Explicit, because the app renders on a server: without one, `use-intl`
60
+ * formats dates in whatever zone the server happens to run in and warns
61
+ * that the client will disagree.
62
+ */
63
+ timeZone: "UTC",
64
+ },
65
+ metadata: {
66
+ shortTitle: "VitNode",
67
+ title: "VitNode",
68
+ },
69
+ plugins: [],
70
+ theme: {
71
+ defaultTheme: "system",
72
+ },
73
+ });
@@ -0,0 +1,23 @@
1
+ import "@tanstack/react-start/server-only";
2
+ import { buildServerConfig } from "@vitnode/core/vitnode.config";
3
+
4
+ import { appMessages } from "#/locales/app";
5
+ import { packageMessages } from "#/locales/packages";
6
+ import { vitNodeConfig } from "#/vitnode.config";
7
+
8
+ /**
9
+ * The half of this app's configuration a browser may never hold.
10
+ *
11
+ * Both entries are `() => import(...)` loaders that read JSON out of a package's
12
+ * build output, which is exactly what `vitnode.config.ts` cannot carry - so they
13
+ * live here, beside the shared config rather than duplicating any of it. The
14
+ * `server-only` marker makes an accidental import from a component a build error
15
+ * instead of a browser bundle with every plugin's AdminCP copy in it.
16
+ *
17
+ * `src/server/messages.server.ts` is the only reader.
18
+ */
19
+ export const vitNodeServerConfig = buildServerConfig({
20
+ config: vitNodeConfig,
21
+ messages: appMessages,
22
+ packageMessages,
23
+ });