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.
- package/LICENSE.md +21 -0
- package/README.md +78 -0
- package/copy-of-vitnode-app/.vscode/settings.json +10 -0
- package/copy-of-vitnode-app/README.md +40 -0
- package/copy-of-vitnode-app/api/.gitignore_template +32 -0
- package/copy-of-vitnode-app/api/drizzle.config.ts +12 -0
- package/copy-of-vitnode-app/api/src/i18n.ts +38 -0
- package/copy-of-vitnode-app/api/src/index.ts +27 -0
- package/copy-of-vitnode-app/api/src/vitnode.api.config.ts +40 -0
- package/copy-of-vitnode-app/api/tsconfig.json +17 -0
- package/copy-of-vitnode-app/api-bun/src/index.ts +16 -0
- package/copy-of-vitnode-app/api-single-app/drizzle.config.ts +12 -0
- package/copy-of-vitnode-app/api-single-app/src/routes/api/$.ts +32 -0
- package/copy-of-vitnode-app/api-single-app/src/server/api-bridge.ts +22 -0
- package/copy-of-vitnode-app/api-single-app/src/server/vitnode-api.server.ts +29 -0
- package/copy-of-vitnode-app/api-single-app/src/vitnode.api.config.ts +37 -0
- package/copy-of-vitnode-app/docker/docker-compose.yml +30 -0
- package/copy-of-vitnode-app/eslint/.prettierrc.mjs +11 -0
- package/copy-of-vitnode-app/eslint/eslint.config.mjs +19 -0
- package/copy-of-vitnode-app/eslint-react/.prettierrc.mjs +11 -0
- package/copy-of-vitnode-app/eslint-react/eslint.config.mjs +19 -0
- package/copy-of-vitnode-app/monorepo/.gitignore_template +41 -0
- package/copy-of-vitnode-app/monorepo/apps/api/.env.example +12 -0
- package/copy-of-vitnode-app/monorepo/apps/web/.env.example +12 -0
- package/copy-of-vitnode-app/monorepo/turbo.json +53 -0
- package/copy-of-vitnode-app/root/.env.example +22 -0
- package/copy-of-vitnode-app/root/.gitignore_template +41 -0
- package/copy-of-vitnode-app/root/global.d.ts +28 -0
- package/copy-of-vitnode-app/root/src/components/admin-shell.tsx +72 -0
- package/copy-of-vitnode-app/root/src/components/main-header.tsx +33 -0
- package/copy-of-vitnode-app/root/src/i18n.ts +36 -0
- package/copy-of-vitnode-app/root/src/lib/admin-auth.ts +11 -0
- package/copy-of-vitnode-app/root/src/lib/admin-nav.ts +43 -0
- package/copy-of-vitnode-app/root/src/lib/admin-search.ts +37 -0
- package/copy-of-vitnode-app/root/src/lib/auth.ts +66 -0
- package/copy-of-vitnode-app/root/src/lib/content-registry.ts +51 -0
- package/copy-of-vitnode-app/root/src/lib/document-headers.ts +128 -0
- package/copy-of-vitnode-app/root/src/lib/i18n/runtime.ts +75 -0
- package/copy-of-vitnode-app/root/src/lib/i18n/shared.ts +20 -0
- package/copy-of-vitnode-app/root/src/lib/navigation.ts +24 -0
- package/copy-of-vitnode-app/root/src/lib/page-head.ts +19 -0
- package/copy-of-vitnode-app/root/src/locales/app.ts +28 -0
- package/copy-of-vitnode-app/root/src/locales/packages.ts +43 -0
- package/copy-of-vitnode-app/root/src/router.tsx +227 -0
- package/copy-of-vitnode-app/root/src/routes/__root.tsx +212 -0
- package/copy-of-vitnode-app/root/src/routes/_admin/admin.core.index.tsx +70 -0
- package/copy-of-vitnode-app/root/src/routes/_admin.tsx +258 -0
- package/copy-of-vitnode-app/root/src/routes/_main/index.tsx +60 -0
- package/copy-of-vitnode-app/root/src/routes/_main.tsx +64 -0
- package/copy-of-vitnode-app/root/src/server/messages.server.ts +29 -0
- package/copy-of-vitnode-app/root/src/start.ts +88 -0
- package/copy-of-vitnode-app/root/src/styles.css +171 -0
- package/copy-of-vitnode-app/root/src/vitnode.config.ts +58 -0
- package/copy-of-vitnode-app/root/src/vitnode.shell.config.ts +35 -0
- package/copy-of-vitnode-app/root/tsconfig.json +29 -0
- package/copy-of-vitnode-app/root/tsr.config.json +3 -0
- package/copy-of-vitnode-app/root/vite.config.ts +119 -0
- package/copy-of-vitnode-plugin/root/.swcrc +26 -0
- package/copy-of-vitnode-plugin/root/global.d.ts +21 -0
- package/copy-of-vitnode-plugin/root/npmignore.template +17 -0
- package/copy-of-vitnode-plugin/root/tsconfig.build.json +5 -0
- package/copy-of-vitnode-plugin/root/tsconfig.json +25 -0
- package/dist/src/create/create-package-json.js +276 -0
- package/dist/src/create/create-vitnode.js +204 -0
- package/dist/src/create/package-versions.js +49 -0
- package/dist/src/helpers/get-available-package-managers.js +24 -0
- package/dist/src/helpers/get-package-json.js +2 -0
- package/dist/src/helpers/get-package-manager-from-root.js +15 -0
- package/dist/src/helpers/get-vitnode-package-version.js +9 -0
- package/dist/src/helpers/init-vitnode.js +34 -0
- package/dist/src/helpers/install-dependencies.js +71 -0
- package/dist/src/helpers/is-folder-empty.js +48 -0
- package/dist/src/helpers/is-online.js +38 -0
- package/dist/src/helpers/is-writeable.js +11 -0
- package/dist/src/helpers/packages-json.js +1 -0
- package/dist/src/helpers/validate-pkg.js +14 -0
- package/dist/src/helpers/with-If.js +1 -0
- package/dist/src/index.js +68 -0
- package/dist/src/plugin/create/add-plugin-to-workspace.js +74 -0
- package/dist/src/plugin/create/create-package-json.js +54 -0
- package/dist/src/plugin/create/create-plugin-vitnode.js +80 -0
- package/dist/src/plugin/create/route-templates.js +144 -0
- package/dist/src/plugin/index.js +29 -0
- package/dist/src/plugin/questions.js +14 -0
- package/dist/src/plugin/validation.js +76 -0
- package/dist/src/prepare/prepare.js +19 -0
- package/dist/src/questions.js +83 -0
- package/dist/src/validation.js +40 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -0
- package/package.json +55 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
2
|
+
import { configureIntl, validateIntlInput } from "@vitnode/core/tanstack/i18n";
|
|
3
|
+
import { IntlProvider } from "use-intl";
|
|
4
|
+
|
|
5
|
+
import { i18n } from "#/i18n";
|
|
6
|
+
import { loadIntlMessages } from "#/server/messages.server";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One language's messages for one set of namespaces, fetched on the server.
|
|
10
|
+
*
|
|
11
|
+
* The one piece of the i18n runtime that cannot live in `@vitnode/core`, and the
|
|
12
|
+
* reason is the compiler rather than the code. A server function has to be
|
|
13
|
+
* transformed by the Start plugin in *both* bundles; the package is externalised
|
|
14
|
+
* from this app's SSR pass, so its modules reach the server un-compiled and a
|
|
15
|
+
* `createServerFn` declared there resolves to `undefined` during SSR with no
|
|
16
|
+
* error. See `packages/vitnode/src/tanstack/boundary.test.ts`.
|
|
17
|
+
*
|
|
18
|
+
* So the wrapper is here and the body is not: `validateIntlInput` is core's, and
|
|
19
|
+
* `loadIntlMessages` delegates to core's loading engine. Start strips the
|
|
20
|
+
* handler - and `#/server/messages.server` with it - out of the client build.
|
|
21
|
+
*/
|
|
22
|
+
export const getIntlMessages = createServerFn()
|
|
23
|
+
.validator(validateIntlInput)
|
|
24
|
+
.handler(async ({ data }) => await loadIntlMessages(data));
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* This app's languages, handed to the package once.
|
|
28
|
+
*
|
|
29
|
+
* Everything in `@vitnode/core/tanstack/i18n` reads what this registers, so a
|
|
30
|
+
* route file imports `RouteMessages` and `intlQueryOptions` straight from the
|
|
31
|
+
* package. What must not happen is a route running before this module has been
|
|
32
|
+
* evaluated - so the two framework entry points, `src/router.tsx` and
|
|
33
|
+
* `src/start.ts`, both import from here, and `src/tests/intl-runtime.test.ts`
|
|
34
|
+
* fails if either stops doing so.
|
|
35
|
+
*
|
|
36
|
+
* The registration is at module scope but reads `getIntlMessages` above only by
|
|
37
|
+
* reference, so the order within this file does not matter: the validator and
|
|
38
|
+
* the fetcher are both called per request, long after it has finished
|
|
39
|
+
* evaluating.
|
|
40
|
+
*/
|
|
41
|
+
export const {
|
|
42
|
+
defaultLocale,
|
|
43
|
+
isLocale: isSupportedLocale,
|
|
44
|
+
localeRouting,
|
|
45
|
+
} = configureIntl({
|
|
46
|
+
fetchMessages: async input => await getIntlMessages({ data: input }),
|
|
47
|
+
/**
|
|
48
|
+
* This app's own `use-intl`, handed over so `RouteMessages` can provide it.
|
|
49
|
+
*
|
|
50
|
+
* `@vitnode/core` is external to this app's SSR pass (`vite.config.ts`), so
|
|
51
|
+
* under `vite dev` Node loads the package and Vite loads this app, and the two
|
|
52
|
+
* resolve `use-intl` to two different files - two `createContext` calls, two
|
|
53
|
+
* React contexts. Everything the package renders reads its own; anything this
|
|
54
|
+
* app renders with its own `useTranslations` - `routes/_main/index.tsx` does -
|
|
55
|
+
* reads this one, and nothing inside the package can import it.
|
|
56
|
+
*
|
|
57
|
+
* So it is registered rather than imported, and `RouteMessages` mounts it
|
|
58
|
+
* outermost. A production build resolves `use-intl` once and the providers
|
|
59
|
+
* collapse into one, which is why leaving this out is a dev-only failure: the
|
|
60
|
+
* route's server render throws "No intl context found", React quietly falls
|
|
61
|
+
* back to client rendering, and the page still appears.
|
|
62
|
+
*/
|
|
63
|
+
hostIntlProvider: IntlProvider,
|
|
64
|
+
i18n,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The router's half of locale routing, bound to this app's languages.
|
|
69
|
+
*
|
|
70
|
+
* Re-exported from here rather than imported straight from the package by
|
|
71
|
+
* `src/router.tsx`: it is the router entry's only i18n import, and routing it
|
|
72
|
+
* through this module is what makes `configureIntl` above run before the router
|
|
73
|
+
* - and therefore before any route, loader or component - exists.
|
|
74
|
+
*/
|
|
75
|
+
export { createLocaleRewrite } from "@vitnode/core/tanstack/i18n";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { i18n } from "#/i18n";
|
|
2
|
+
|
|
3
|
+
import { localeRouting } from "#/lib/i18n/runtime";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A language this app serves, as a type. `"en" | "pl"`, derived from the config
|
|
7
|
+
* rather than written twice.
|
|
8
|
+
*
|
|
9
|
+
* The one i18n thing this app still owns, and it has to: `@vitnode/core` is
|
|
10
|
+
* installed by apps with different language lists, so it types a locale as
|
|
11
|
+
* `string` and takes this union as a type argument where the value originates
|
|
12
|
+
* (`useLocale<Locale>()`, `resolveLocale<Locale>()`).
|
|
13
|
+
*/
|
|
14
|
+
export type Locale = (typeof i18n.locales)[number]["code"];
|
|
15
|
+
|
|
16
|
+
export { defaultLocale, localeRouting } from "#/lib/i18n/runtime";
|
|
17
|
+
|
|
18
|
+
/** Narrows a string - a URL segment, a cookie, a `<select>` value - to a locale. */
|
|
19
|
+
export const isLocale = (value: null | string | undefined): value is Locale =>
|
|
20
|
+
localeRouting.isSupportedLocale(value);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createAuthNavigation } from "@vitnode/core/tanstack/auth";
|
|
2
|
+
|
|
3
|
+
import { localeRouting } from "#/lib/i18n/shared";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Going somewhere in this application from code, bound to this app's languages.
|
|
7
|
+
*
|
|
8
|
+
* One line of application, and everything else is
|
|
9
|
+
* `@vitnode/core/tanstack/auth`: the two questions a user-supplied target has to
|
|
10
|
+
* answer (may we send a browser there, and what does the router want to be
|
|
11
|
+
* handed), the reason a redirect carries `to` rather than `href`, and the fact
|
|
12
|
+
* that the same decision is made on a server and in a browser.
|
|
13
|
+
*
|
|
14
|
+
* What is left here is the only thing a package cannot answer - which languages
|
|
15
|
+
* this installation serves, which is what decides whether `/pl/discover` is a
|
|
16
|
+
* Polish page or a route called `pl`.
|
|
17
|
+
*
|
|
18
|
+
* `@vitnode/core/tanstack/routes` builds its own from the same factory, handed
|
|
19
|
+
* the same `localeRouting`, so core's auth screens and this app's AdminCP command
|
|
20
|
+
* palette navigate by one rule rather than two.
|
|
21
|
+
*/
|
|
22
|
+
export const { internalDestination, useAppNavigate } = createAuthNavigation({
|
|
23
|
+
localeRouting,
|
|
24
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createRouteHead } from "@vitnode/core/tanstack/metadata";
|
|
2
|
+
|
|
3
|
+
import { vitNodeShellConfig } from "#/vitnode.shell.config";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A route's `head`, bound to this app's name.
|
|
7
|
+
*
|
|
8
|
+
* Two lines of application, and everything else is
|
|
9
|
+
* `@vitnode/core/tanstack/metadata`: the `"<page> - <site>"` title rule Next.js
|
|
10
|
+
* applies through `title.template`, the decision that a robots directive is
|
|
11
|
+
* stated rather than assumed, and the handling of a `loaderData` that is
|
|
12
|
+
* `undefined` on a route's first pass.
|
|
13
|
+
*
|
|
14
|
+
* What is left here is the only thing a package cannot answer - this site's own
|
|
15
|
+
* name, which is what every tab title ends with.
|
|
16
|
+
*
|
|
17
|
+
* head: ({ loaderData }) => pageHead({ robots: 'index, follow', ...loaderData })
|
|
18
|
+
*/
|
|
19
|
+
export const pageHead = createRouteHead(vitNodeShellConfig.metadata);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { AppMessagesMap } from "@vitnode/core/lib/i18n/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Translations this app owns, on top of whatever the packages ship.
|
|
5
|
+
*
|
|
6
|
+
* Empty, and usually stays that way. Every VitNode package ships its own
|
|
7
|
+
* translations and `locales/packages.ts` is what registers them - carrying a
|
|
8
|
+
* second copy of a string a package already owns is how one product comes to
|
|
9
|
+
* spell the same settings tab two different ways.
|
|
10
|
+
*
|
|
11
|
+
* What belongs here is a string this app *changes*. Add a locale, then the key
|
|
12
|
+
* you are rewording:
|
|
13
|
+
*
|
|
14
|
+
* export const appMessages: AppMessagesMap = {
|
|
15
|
+
* en: {
|
|
16
|
+
* '@vitnode/core': async () => await import('./en.json'),
|
|
17
|
+
* },
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* Deep-merged last, so a file here only needs the keys it actually changes:
|
|
21
|
+
* everything it leaves out falls back to the package's, and then to the default
|
|
22
|
+
* locale, key by key.
|
|
23
|
+
*
|
|
24
|
+
* Server-side only, and kept out of `src/i18n.ts` on purpose: these are
|
|
25
|
+
* functions, and `src/i18n.ts` is spread into the shell config, which crosses to
|
|
26
|
+
* the browser and has to stay serializable.
|
|
27
|
+
*/
|
|
28
|
+
export const appMessages: AppMessagesMap = {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { LocaleMessagesMap } from "@vitnode/core/lib/i18n/types";
|
|
2
|
+
|
|
3
|
+
import { CONFIG_PLUGIN as CORE } from "@vitnode/core/config";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Where this app reads each installed package's translations from.
|
|
7
|
+
*
|
|
8
|
+
* Every VitNode package ships a locale barrel - `@vitnode/core/locales/index` -
|
|
9
|
+
* that loads its own files with a runtime
|
|
10
|
+
* `import("./en.json", { with: { type: "json" } })`. Under Node that is exactly
|
|
11
|
+
* right, and it is how an API app reads them.
|
|
12
|
+
*
|
|
13
|
+
* It cannot work here, and the reason is the import attribute rather than
|
|
14
|
+
* anything about VitNode. Vite and Nitro inline `@vitnode/core`'s build output
|
|
15
|
+
* into this app's server chunks - `ssr.external` applies to the SSR pass, not to
|
|
16
|
+
* Nitro's own bundling - but Rollup will not follow a dynamic import that
|
|
17
|
+
* carries `with: { type: "json" }`, so it neither emits the JSON nor rewrites
|
|
18
|
+
* the specifier. What ships is a relative import pointing next to a chunk that
|
|
19
|
+
* the JSON was never copied to, and every string on the page renders as its own
|
|
20
|
+
* key.
|
|
21
|
+
*
|
|
22
|
+
* So the loaders are declared here instead, with static specifiers a bundler can
|
|
23
|
+
* follow. Each resolves through the package's `./locales/*.json` export to the
|
|
24
|
+
* real file and lands in the build as a chunk fetched on demand, which is the
|
|
25
|
+
* same laziness the barrels wanted.
|
|
26
|
+
*
|
|
27
|
+
* **Add a line here for every plugin you install.** A plugin registered in
|
|
28
|
+
* `vitnode.config.ts` with no entry in this map renders its own strings as keys:
|
|
29
|
+
*
|
|
30
|
+
* import { CONFIG_PLUGIN as BLOG } from '@acme/blog/const'
|
|
31
|
+
*
|
|
32
|
+
* [BLOG.pluginId]: {
|
|
33
|
+
* en: async () => await import('@acme/blog/locales/en.json'),
|
|
34
|
+
* },
|
|
35
|
+
*
|
|
36
|
+
* This is the app's *only* copy of that list - `vitnode.config.ts` and
|
|
37
|
+
* `server/messages.server.ts` both read it from here.
|
|
38
|
+
*/
|
|
39
|
+
export const packageMessages: Record<string, LocaleMessagesMap> = {
|
|
40
|
+
[CORE.pluginId]: {
|
|
41
|
+
en: async () => await import("@vitnode/core/locales/en.json"),
|
|
42
|
+
},
|
|
43
|
+
};
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import type { AnyRouter } from "@tanstack/react-router";
|
|
2
|
+
|
|
3
|
+
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
|
|
4
|
+
import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query";
|
|
5
|
+
import { createVitNodeQueryClient } from "@vitnode/core/lib/query-client";
|
|
6
|
+
import { RoutePendingSpinner } from "@vitnode/core/tanstack/pending";
|
|
7
|
+
import {
|
|
8
|
+
pluginRouteSpecs,
|
|
9
|
+
withPluginRoutes,
|
|
10
|
+
} from "@vitnode/core/tanstack/plugin-routes";
|
|
11
|
+
import {
|
|
12
|
+
withCoreAdminRoutes,
|
|
13
|
+
withCoreMainRoutes,
|
|
14
|
+
withCoreRootRoutes,
|
|
15
|
+
} from "@vitnode/core/tanstack/routes";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The auth transport, registered by importing the module that declares it.
|
|
19
|
+
*
|
|
20
|
+
* `@vitnode/core/tanstack/auth` owns every auth decision this app makes but may
|
|
21
|
+
* not declare a `createServerFn` - uncompiled on the server, one silently
|
|
22
|
+
* resolves to `undefined` - so `lib/auth.ts` declares the eight wrappers and
|
|
23
|
+
* hands them over at module scope. A bare import because there is nothing to
|
|
24
|
+
* name: the registration *is* the module's effect.
|
|
25
|
+
*
|
|
26
|
+
* Here because a router is the one module both entry points load, so the
|
|
27
|
+
* registration has happened before any route, loader or component can reach for
|
|
28
|
+
* it, in the browser bundle and on the server alike.
|
|
29
|
+
*/
|
|
30
|
+
import "./lib/auth";
|
|
31
|
+
/**
|
|
32
|
+
* The admin transport, registered the same way and for the same reason.
|
|
33
|
+
*
|
|
34
|
+
* One server function rather than eight, reading the AdminCP's own session under
|
|
35
|
+
* its own cookie. It is a separate registration from the auth one on purpose:
|
|
36
|
+
* they are two sessions, two cookies and two cache entries, and nothing in
|
|
37
|
+
* VitNode may let the public session answer an admin question.
|
|
38
|
+
*/
|
|
39
|
+
import "./lib/admin-auth";
|
|
40
|
+
import { createLocaleRewrite, localeRouting } from "./lib/i18n/runtime";
|
|
41
|
+
import { pageHead } from "./lib/page-head";
|
|
42
|
+
import { pluginRouteSources } from "./plugin-routes.gen";
|
|
43
|
+
import { Route as adminShellRoute } from "./routes/_admin";
|
|
44
|
+
import { Route as mainShellRoute } from "./routes/_main";
|
|
45
|
+
import { routeTree as fileRouteTree } from "./routeTree.gen";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The Content Engine registry, behind a literal dynamic import.
|
|
49
|
+
*
|
|
50
|
+
* Awaited by the one loader that needs it - `/admin/content/*` - rather than
|
|
51
|
+
* imported here. Building the registry reaches `@vitnode/core/content` and
|
|
52
|
+
* every configured plugin's admin form components, and this module is the one
|
|
53
|
+
* the client entry evaluates on every page: as a static import it put `zod`,
|
|
54
|
+
* every plugin's content registrations, the content form primitives and
|
|
55
|
+
* `react-hook-form` in front of the front page's first paint. See
|
|
56
|
+
* `CoreAdminRouteContext.loadContentRegistry`.
|
|
57
|
+
*/
|
|
58
|
+
const loadContentRegistry = async () =>
|
|
59
|
+
(await import("./lib/content-registry")).contentRegistry;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* One route tree: this app's route files, plus the AdminCP screens `@vitnode/core`
|
|
63
|
+
* owns, plus the pages its plugins declare.
|
|
64
|
+
*
|
|
65
|
+
* At module scope rather than inside `getRouter`, because `getRouter` runs once
|
|
66
|
+
* per server request and mounting the plugin routes mutates the route tree - the
|
|
67
|
+
* generated tree is a module singleton. `withPluginRoutes` is idempotent anyway;
|
|
68
|
+
* doing it once is simply where it belongs.
|
|
69
|
+
*
|
|
70
|
+
* The plugin half comes from one generated file: a static import of each
|
|
71
|
+
* configured plugin's own route tree. No plugin page is copied into
|
|
72
|
+
* `src/routes`, no route path is written by hand, and nothing here knows which
|
|
73
|
+
* plugins are installed - see `@vitnode/core/tanstack/plugin-routes`.
|
|
74
|
+
*
|
|
75
|
+
* A page is reached only through the literal `lazy(() => import(...))` its route
|
|
76
|
+
* declared, so every one of them is a chunk of its own. The one part of a plugin
|
|
77
|
+
* route that is not lazy is a `search` schema: a router's `validateSearch` runs
|
|
78
|
+
* during path matching, before any chunk is fetched, so it lives in the tree
|
|
79
|
+
* rather than in the page.
|
|
80
|
+
*
|
|
81
|
+
* `mountUnder` names one route per shell, which is the whole of what "a plugin
|
|
82
|
+
* route renders in the application shell" amounts to here. A plugin declares
|
|
83
|
+
* `area: "main"` or `area: "admin"`; `_main` is the route that renders the
|
|
84
|
+
* public shell and `_admin` the one that renders the AdminCP, and being a child
|
|
85
|
+
* of one of them is what gives `/example` the header and the one `<main>` that
|
|
86
|
+
* `/discover` has, or gives `/admin/reports` the sidebar, the breadcrumb area
|
|
87
|
+
* and the admin session guard that `/admin/core` has. No new field, no per-route
|
|
88
|
+
* layout metadata and no second copy of either shell - route composition, which
|
|
89
|
+
* the area declaration already described.
|
|
90
|
+
*
|
|
91
|
+
* Neither shell changes a path: both are pathless, so `/example` stays
|
|
92
|
+
* `/example` and an admin plugin route's `/admin/…` is the path its own route
|
|
93
|
+
* spells out in full. An area VitNode knows and this app has not named here
|
|
94
|
+
* fails the composition rather than being mounted under the other one.
|
|
95
|
+
*
|
|
96
|
+
* `_main` and `_admin` are imported for their route objects, and they are the
|
|
97
|
+
* same objects the generated tree holds: `createFileRoute` produces one instance
|
|
98
|
+
* per module and `routeTree.gen.ts` mutates it in place.
|
|
99
|
+
*
|
|
100
|
+
* `withCoreMainRoutes`, `withCoreAdminRoutes` and `withCoreRootRoutes` mount
|
|
101
|
+
* core's own screens the same way, one per mount point: the public pages under
|
|
102
|
+
* the main shell, the AdminCP's under its own, and the shell-less ones - the auth
|
|
103
|
+
* cards and the AdminCP sign-in - straight under the root.
|
|
104
|
+
*
|
|
105
|
+
* They were twenty-nine route files in this application until
|
|
106
|
+
* `@vitnode/core/tanstack/routes` existed, every one of them pure wiring around
|
|
107
|
+
* something imported from the package - so an app carried a copy of VitNode's own
|
|
108
|
+
* routing table and core adding a screen meant an edit here. They are code-based
|
|
109
|
+
* rather than declared as plugin routes because they need the router's full
|
|
110
|
+
* option set: a real `validateSearch` that clamps `?page=999` before anything
|
|
111
|
+
* renders, a `beforeLoad` guard that runs before any chunk is fetched, and a
|
|
112
|
+
* splat path a plugin route path does not represent.
|
|
113
|
+
*
|
|
114
|
+
* `localeRouting` goes to the last of the three because a sign-in navigates to a
|
|
115
|
+
* path a *visitor* supplied: the route tree carries no locale, so the prefix has
|
|
116
|
+
* to be stripped before the router sees it, and which prefixes exist is this
|
|
117
|
+
* app's answer. It is the same object the `rewrite` below uses.
|
|
118
|
+
*
|
|
119
|
+
* `pageHead` is this app's own `createRouteHead(metadata)` binding, handed over
|
|
120
|
+
* because a package cannot know the site's name: a plugin page's `<title>` goes
|
|
121
|
+
* through the same `"<page> - <site>"` rule every other VitNode page's does,
|
|
122
|
+
* rather than through a second one the plugin invented.
|
|
123
|
+
*/
|
|
124
|
+
const routeTree = withCoreRootRoutes(
|
|
125
|
+
withCoreAdminRoutes(
|
|
126
|
+
withCoreMainRoutes(
|
|
127
|
+
withPluginRoutes(
|
|
128
|
+
fileRouteTree,
|
|
129
|
+
pluginRouteSpecs(pluginRouteSources),
|
|
130
|
+
{
|
|
131
|
+
mountUnder: { admin: adminShellRoute, main: mainShellRoute },
|
|
132
|
+
pageHead,
|
|
133
|
+
},
|
|
134
|
+
),
|
|
135
|
+
{ mountUnder: mainShellRoute, pageHead },
|
|
136
|
+
),
|
|
137
|
+
{ loadContentRegistry, mountUnder: adminShellRoute, pageHead },
|
|
138
|
+
),
|
|
139
|
+
{ localeRouting, mountUnder: fileRouteTree, pageHead },
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The app's router, and the QueryClient it owns.
|
|
144
|
+
*
|
|
145
|
+
* Start calls this once per server request and once in the browser, which is
|
|
146
|
+
* exactly the lifetime a QueryClient should have: created here, it is per
|
|
147
|
+
* request on the server - never a module-level client shared by every visitor
|
|
148
|
+
* being rendered at once - and a single long-lived one on the client.
|
|
149
|
+
*
|
|
150
|
+
* It goes into the router context, so a route loader reaches it as
|
|
151
|
+
* `context.queryClient` and can `ensureQueryData` before its component renders.
|
|
152
|
+
* That is the whole point of putting it here rather than in a provider: a
|
|
153
|
+
* loader runs before React does, so a client mounted by a component would be
|
|
154
|
+
* out of reach of the code that most wants it.
|
|
155
|
+
*
|
|
156
|
+
* `setupRouterSsrQueryIntegration` wires the two together: it dehydrates the
|
|
157
|
+
* cache into the SSR stream (including queries that resolve mid-render),
|
|
158
|
+
* hydrates it on the client before the first render, routes `redirect()` thrown
|
|
159
|
+
* inside a query or mutation through the router, and wraps the app in the one
|
|
160
|
+
* `QueryClientProvider` for this client. Nothing else in this app may create a
|
|
161
|
+
* `QueryClient` or a provider for one - two clients in a page means a query a
|
|
162
|
+
* loader cached is invisible to the component that reads it.
|
|
163
|
+
*
|
|
164
|
+
* `defaultPreloadStaleTime: 0` leaves caching to Query rather than having the
|
|
165
|
+
* router keep a second copy of the same data with its own expiry.
|
|
166
|
+
*
|
|
167
|
+
* `defaultStaleReloadMode: 'blocking'` is what makes a route's pending shape
|
|
168
|
+
* reachable at all once preloading is on. Router core's default is
|
|
169
|
+
* `'background'`, and a background reload never opens a pending window - but it
|
|
170
|
+
* still waits for the route's component chunk before it commits. So the common
|
|
171
|
+
* desktop path, hover a link and click it, took the one branch that renders
|
|
172
|
+
* nothing: the hover filled the loader, the click was therefore a background
|
|
173
|
+
* reload, and the chunk downloaded with the previous page still on screen and
|
|
174
|
+
* no skeleton in sight.
|
|
175
|
+
*
|
|
176
|
+
* It costs nothing here because a VitNode loader does not block on a warm cache:
|
|
177
|
+
* `ensureQueryData` with `revalidateIfStale` hands back the cached entry and
|
|
178
|
+
* refreshes behind it, so "blocking" describes a promise that resolves in a
|
|
179
|
+
* microtask. What changes is only that the router now marks the match pending
|
|
180
|
+
* while that happens, which is what `defaultPendingMs` is for.
|
|
181
|
+
*
|
|
182
|
+
* `defaultPendingMs: 150` is that threshold, and it is not zero for the same
|
|
183
|
+
* reason. At zero, every navigation opens a pending window, so
|
|
184
|
+
* `defaultPendingMinMs` holds a *fully cached* navigation behind a skeleton for
|
|
185
|
+
* 300ms - a page that could have been instant, made slow to look busy. At 150ms
|
|
186
|
+
* a cached navigation goes straight through with nothing shown and a slow one
|
|
187
|
+
* still gets its shape, in the content area and the breadcrumb together.
|
|
188
|
+
*
|
|
189
|
+
* `rewrite` is what makes one route tree serve two public URL shapes: `/pl/...`
|
|
190
|
+
* arrives, `/...` is matched, and every link the router builds gets the prefix
|
|
191
|
+
* back. No route file mentions a locale, so nothing here has to be duplicated
|
|
192
|
+
* per language - see `@vitnode/core/tanstack/i18n`.
|
|
193
|
+
*/
|
|
194
|
+
export function getRouter() {
|
|
195
|
+
const queryClient = createVitNodeQueryClient();
|
|
196
|
+
|
|
197
|
+
// The rewrite reads the locale off the router's own current location, and the
|
|
198
|
+
// router needs the rewrite to parse that location - so it is handed a getter
|
|
199
|
+
// rather than the router itself. `output` only ever runs once a link is built,
|
|
200
|
+
// which is long after the assignment below.
|
|
201
|
+
const holder: { current?: AnyRouter } = {};
|
|
202
|
+
|
|
203
|
+
const router = createTanStackRouter({
|
|
204
|
+
context: { queryClient },
|
|
205
|
+
defaultPendingComponent: RoutePendingSpinner,
|
|
206
|
+
defaultPendingMs: 150,
|
|
207
|
+
defaultPendingMinMs: 300,
|
|
208
|
+
defaultPreload: "intent",
|
|
209
|
+
defaultPreloadStaleTime: 0,
|
|
210
|
+
defaultStaleReloadMode: "blocking",
|
|
211
|
+
rewrite: createLocaleRewrite(() => holder.current),
|
|
212
|
+
routeTree,
|
|
213
|
+
scrollRestoration: true,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
holder.current = router;
|
|
217
|
+
|
|
218
|
+
setupRouterSsrQueryIntegration({ queryClient, router });
|
|
219
|
+
|
|
220
|
+
return router;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
declare module "@tanstack/react-router" {
|
|
224
|
+
interface Register {
|
|
225
|
+
router: ReturnType<typeof getRouter>;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import type { QueryClient } from "@tanstack/react-query";
|
|
2
|
+
|
|
3
|
+
import { TanStackDevtools } from "@tanstack/react-devtools";
|
|
4
|
+
import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools";
|
|
5
|
+
import {
|
|
6
|
+
createRootRouteWithContext,
|
|
7
|
+
HeadContent,
|
|
8
|
+
Outlet,
|
|
9
|
+
Scripts,
|
|
10
|
+
} from "@tanstack/react-router";
|
|
11
|
+
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
|
|
12
|
+
import { ThemeScript } from "@vitnode/core/components/theme-script";
|
|
13
|
+
import {
|
|
14
|
+
intlQueryOptions,
|
|
15
|
+
publicPathnameOf,
|
|
16
|
+
resolveLocale,
|
|
17
|
+
useLocale,
|
|
18
|
+
} from "@vitnode/core/tanstack/i18n";
|
|
19
|
+
import {
|
|
20
|
+
ErrorActions,
|
|
21
|
+
NotFound,
|
|
22
|
+
VitNodeRootProviders,
|
|
23
|
+
} from "@vitnode/core/tanstack/layout";
|
|
24
|
+
|
|
25
|
+
import type { Locale } from "#/lib/i18n/shared";
|
|
26
|
+
|
|
27
|
+
import { vitNodeShellConfig } from "#/vitnode.shell.config";
|
|
28
|
+
|
|
29
|
+
import appCss from "../styles.css?url";
|
|
30
|
+
|
|
31
|
+
const { debug, i18n, metadata, theme } = vitNodeShellConfig;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What the router itself provides, before any route has run.
|
|
35
|
+
*
|
|
36
|
+
* The QueryClient, and nothing else. `beforeLoad` below adds `locale` on top, so
|
|
37
|
+
* what a loader actually receives is `{ queryClient, locale }` - the language
|
|
38
|
+
* included, because a loader that fetches anything user-facing needs to know
|
|
39
|
+
* which one it is fetching.
|
|
40
|
+
*/
|
|
41
|
+
export interface RootRouterContext {
|
|
42
|
+
queryClient: QueryClient;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const Route = createRootRouteWithContext<RootRouterContext>()({
|
|
46
|
+
/**
|
|
47
|
+
* The request's language, resolved once and handed to every loader below.
|
|
48
|
+
*
|
|
49
|
+
* The same function the rewrite and the components use, so there is one answer
|
|
50
|
+
* per request rather than one per consumer. Note that a language switch does
|
|
51
|
+
* not change the *internal* URL - only the public one - so the switcher
|
|
52
|
+
* invalidates the router to bring this back in step.
|
|
53
|
+
*/
|
|
54
|
+
beforeLoad: ({ location }) => ({
|
|
55
|
+
locale: resolveLocale<Locale>(publicPathnameOf(location)),
|
|
56
|
+
}),
|
|
57
|
+
component: RootComponent,
|
|
58
|
+
head: () => ({
|
|
59
|
+
links: [
|
|
60
|
+
{ href: appCss, rel: "stylesheet" },
|
|
61
|
+
/*
|
|
62
|
+
* The tab icon, from `public/favicon.ico`.
|
|
63
|
+
*
|
|
64
|
+
* Stated rather than left to the browser's automatic `/favicon.ico`
|
|
65
|
+
* request, because that request is a 404 until the file exists and a
|
|
66
|
+
* silently missing icon is easy to never notice. Drop your own 32px `.ico`
|
|
67
|
+
* at `public/favicon.ico`; it lives there rather than being imported so
|
|
68
|
+
* that the URL is stable and the file is still reachable at the well-known
|
|
69
|
+
* path browsers ask for unprompted.
|
|
70
|
+
*/
|
|
71
|
+
{
|
|
72
|
+
href: "/favicon.ico",
|
|
73
|
+
rel: "icon",
|
|
74
|
+
sizes: "32x32",
|
|
75
|
+
type: "image/x-icon",
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
meta: [
|
|
79
|
+
{ charSet: "utf-8" },
|
|
80
|
+
{ content: "width=device-width, initial-scale=1", name: "viewport" },
|
|
81
|
+
// The default title, from the app's config. A route that names itself
|
|
82
|
+
// renders `"<page> - <shortTitle>"` instead, through `formatPageTitle` -
|
|
83
|
+
// the same rule Next.js applies through `title.template`.
|
|
84
|
+
{ title: metadata.title },
|
|
85
|
+
],
|
|
86
|
+
}),
|
|
87
|
+
/**
|
|
88
|
+
* Warm this language's shell strings before anything renders.
|
|
89
|
+
*
|
|
90
|
+
* `context.locale` rather than a default: `/pl` has to arrive with Polish
|
|
91
|
+
* already in the cache, or the first paint is English and the page flips after
|
|
92
|
+
* hydration.
|
|
93
|
+
*/
|
|
94
|
+
loader: async ({ context }) => {
|
|
95
|
+
await context.queryClient.ensureQueryData(
|
|
96
|
+
intlQueryOptions({ locale: context.locale }),
|
|
97
|
+
);
|
|
98
|
+
},
|
|
99
|
+
/**
|
|
100
|
+
* Every URL this application does not serve.
|
|
101
|
+
*
|
|
102
|
+
* The last resort, and until now there was none: a path that matched no route
|
|
103
|
+
* at all fell through to TanStack Router's own `<p>Not Found</p>` - no shell,
|
|
104
|
+
* no strings, no way back - and the router warned about the missing option on
|
|
105
|
+
* every such navigation. `/admin/contents` and a hand-typed `/blog/post-30`
|
|
106
|
+
* both landed there.
|
|
107
|
+
*
|
|
108
|
+
* ## What reaches it, and what does not
|
|
109
|
+
*
|
|
110
|
+
* Only a path **no route matched**. A route that matched and then answered
|
|
111
|
+
* `notFound()` from its own loader is caught by the nearest
|
|
112
|
+
* `notFoundComponent` above it, which is why `/admin/content/nope` and
|
|
113
|
+
* `/admin/content/blog/articles/999999/edit` render the AdminCP's 404 inside
|
|
114
|
+
* the panel rather than this - see `_admin`'s, which mounts the shell around
|
|
115
|
+
* the same message.
|
|
116
|
+
*
|
|
117
|
+
* ## It is a 404, and not a redirect anywhere
|
|
118
|
+
*
|
|
119
|
+
* The route tree is the whole application, so a URL that reaches here is one
|
|
120
|
+
* somebody typed or a stale bookmark, and saying so is the honest answer.
|
|
121
|
+
* Bouncing an unmatched path at some other origin would hide a genuinely
|
|
122
|
+
* missing page behind a hop to a server that 404s it anyway. Where a URL is
|
|
123
|
+
* served from is a deployment question, and a proxy in front of the app
|
|
124
|
+
* answers it better than this route can.
|
|
125
|
+
*/
|
|
126
|
+
notFoundComponent: RootNotFound,
|
|
127
|
+
shellComponent: RootDocument,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Rendered inside `RootComponent`, which is what makes the strings work: the
|
|
132
|
+
* providers it mounts include `RouteMessages` with `core.global`, and that is
|
|
133
|
+
* the namespace `NotFound` reads its two lines from. The root's loader has
|
|
134
|
+
* already warmed that entry, so nothing suspends.
|
|
135
|
+
*/
|
|
136
|
+
function RootNotFound() {
|
|
137
|
+
return <NotFound actions={<ErrorActions />} />;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The VitNode provider tree, mounted once above every route.
|
|
142
|
+
*
|
|
143
|
+
* Every provider in it is shared with the Next.js app - the theme, the toaster,
|
|
144
|
+
* the tooltip provider, the WebSocket - plus the pair of `use-intl` records that
|
|
145
|
+
* only a package can name. `VitNodeRootProviders` owns all of it, including the
|
|
146
|
+
* argument for why the realtime listeners are inside it rather than in the main
|
|
147
|
+
* shell.
|
|
148
|
+
*
|
|
149
|
+
* What this route contributes is the two things an application owns: which
|
|
150
|
+
* languages it serves, and its theme defaults.
|
|
151
|
+
*
|
|
152
|
+
* Because the locale comes from router state, changing language re-renders this:
|
|
153
|
+
* new locale, new query key, new messages, no page reload.
|
|
154
|
+
*/
|
|
155
|
+
function RootComponent() {
|
|
156
|
+
return (
|
|
157
|
+
<VitNodeRootProviders config={{ debug, locales: i18n.locales, theme }}>
|
|
158
|
+
<Outlet />
|
|
159
|
+
</VitNodeRootProviders>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The document itself.
|
|
165
|
+
*
|
|
166
|
+
* `lang` is the language this request actually resolved to - `en` for `/`, `pl`
|
|
167
|
+
* for `/pl`, and on a route outside the localized URL space (`/admin`) whatever
|
|
168
|
+
* the visitor's cookie says. It comes from the same router state the provider
|
|
169
|
+
* reads, so the two cannot disagree and hydration has nothing to complain about.
|
|
170
|
+
*
|
|
171
|
+
* `ThemeScript` has to be in the head, and it has to be inline: it applies the
|
|
172
|
+
* stored theme to `<html>` before the browser paints, so the first frame is the
|
|
173
|
+
* theme the visitor chose rather than a flash of the default one.
|
|
174
|
+
* `suppressHydrationWarning` covers the attributes it writes, which by design
|
|
175
|
+
* differ from what the server rendered.
|
|
176
|
+
*/
|
|
177
|
+
function RootDocument({ children }: { children: React.ReactNode }) {
|
|
178
|
+
const locale = useLocale();
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<html lang={locale} suppressHydrationWarning>
|
|
182
|
+
<head>
|
|
183
|
+
<HeadContent />
|
|
184
|
+
<ThemeScript {...theme} />
|
|
185
|
+
</head>
|
|
186
|
+
|
|
187
|
+
<body suppressHydrationWarning>
|
|
188
|
+
{children}
|
|
189
|
+
|
|
190
|
+
{import.meta.env.DEV ? (
|
|
191
|
+
<TanStackDevtools
|
|
192
|
+
config={{
|
|
193
|
+
position: "bottom-right",
|
|
194
|
+
}}
|
|
195
|
+
plugins={[
|
|
196
|
+
{
|
|
197
|
+
name: "TanStack Router",
|
|
198
|
+
render: <TanStackRouterDevtoolsPanel />,
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
name: "TanStack Query",
|
|
202
|
+
render: <ReactQueryDevtoolsPanel />,
|
|
203
|
+
},
|
|
204
|
+
]}
|
|
205
|
+
/>
|
|
206
|
+
) : null}
|
|
207
|
+
|
|
208
|
+
<Scripts />
|
|
209
|
+
</body>
|
|
210
|
+
</html>
|
|
211
|
+
);
|
|
212
|
+
}
|