@antelopejs/dms-frontend 0.3.12 → 0.5.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/README.md +77 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.js +5 -1
- package/dist/derived-outputs.d.ts +2 -0
- package/dist/derived-outputs.js +22 -3
- package/dist/help.js +4 -0
- package/dist/layers.js +1 -0
- package/dist/workspace.d.ts +1 -0
- package/package.json +1 -1
- package/templates/vue/app-runtime.ts +42 -26
- package/templates/vue/frontend-build-loader.ts +78 -0
- package/templates/vue/frontend-build.ts +41 -0
- package/templates/vue/frontend-module.ts +153 -15
- package/templates/vue/icon-hydration.ts +78 -0
- package/templates/vue/server/auth/backend.mjs +20 -6
- package/templates/vue/server/auth/client-ip.mjs +28 -0
- package/templates/vue/server/auth/routes.mjs +1 -0
- package/templates/vue/server/auth/session.mjs +6 -0
- package/templates/vue/server/icons.mjs +43 -1
- package/templates/vue/server.mjs +14 -13
- package/templates/vue/ssr-renderer.ts +18 -1
- package/templates/vue/vite.config.ts +25 -23
package/README.md
CHANGED
|
@@ -188,6 +188,72 @@ The SDK also exposes `use` for Vue plugins. Entries execute by descending manife
|
|
|
188
188
|
|
|
189
189
|
Within one setup, the first module to register a name keeps it. `ajs-dms dev` sets every module up again each time a change to a layer reaches the server renderer, into empty registries, so the next server render uses the edited component without a restart. A `setup` may therefore run more than once in a process: it should only register, and keep no state of its own between runs.
|
|
190
190
|
|
|
191
|
+
### Component prefix
|
|
192
|
+
|
|
193
|
+
Every registered component joins the application's global components and resolves by name anywhere, in templates and in backend page trees. To keep the components of two modules from colliding, a module declares a `componentPrefix`, put in front of the name of every component it registers:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
const frontendModule: DmsFrontendModule = {
|
|
197
|
+
componentPrefix: "Acme",
|
|
198
|
+
setup(sdk) {
|
|
199
|
+
sdk.registerComponent("Banner", Banner); // registered as AcmeBanner
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
The full name, prefix included, is the one a template uses (`<AcmeBanner>`) and a backend sends (`CustomComponent("AcmeBanner")`, or `acme-banner`). Names are compared ignoring case, a leading `lazy` and non-alphanumeric characters; a leading `Dms` is no longer dropped, since it is the DMS module's own prefix.
|
|
205
|
+
|
|
206
|
+
When two modules register the same name, the module of higher priority keeps it and a warning names both. A module that means to replace another's component registers its full name without its own prefix, which is not reported:
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
sdk.registerComponent("AcmeBanner", MyBanner, { prefix: false });
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
A module's `app/build/` directory is never auto-imported (see below): its components are registered so backend pages can name them, but they are the module's internals, not a surface other modules should build on.
|
|
213
|
+
|
|
214
|
+
### Auto-imports (`dms.frontend.build.ts`)
|
|
215
|
+
|
|
216
|
+
Nothing a module ships is auto-imported unless the module declares it, in an optional `dms.frontend.build.ts` next to its `dms.frontend.ts`:
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
import { defineDmsFrontendBuild } from "#dms/frontend-build";
|
|
220
|
+
|
|
221
|
+
export default defineDmsFrontendBuild((build) => {
|
|
222
|
+
build.registerAutoImports([
|
|
223
|
+
"layers/*/app/composables",
|
|
224
|
+
"layers/*/app/utils",
|
|
225
|
+
"layers/*/app/types",
|
|
226
|
+
]);
|
|
227
|
+
});
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
Each entry is a directory relative to the module root, glob patterns allowed; every export of the files under it becomes available to the whole application without an import, in scripts and templates. A module without the file has nothing auto-imported. `app/build/` holds a module's private code and is never auto-imported: it is left out of what a broader entry covers, and an entry naming it, or a directory outside the module, is ignored with a warning when Vite starts.
|
|
231
|
+
|
|
232
|
+
The file runs in Node.js when Vite starts, under `ajs dms dev` and `ajs dms build`, not in the application: it may import `#dms/frontend-build` and Node.js built-ins, never the application's code. Vite restarts when one changes.
|
|
233
|
+
|
|
234
|
+
Up to 0.3, every module's `app/composables`, `app/types`, `app/utils`, `app/build/composables` and `app/build/types` were auto-imported. A module that relies on auto-imports declares its public directories in `dms.frontend.build.ts`, and imports its `app/build/` code by path.
|
|
235
|
+
|
|
236
|
+
### Page loading state
|
|
237
|
+
|
|
238
|
+
After a client navigation, the page renders under a `<Suspense>` until its chunk and any async `setup` resolve. `useDmsPageLoading()` (auto-imported) is `true` meanwhile, so a layout can show its own placeholder instead of an empty page body:
|
|
239
|
+
|
|
240
|
+
```vue
|
|
241
|
+
<script setup lang="ts">
|
|
242
|
+
const pageLoading = useDmsPageLoading();
|
|
243
|
+
</script>
|
|
244
|
+
|
|
245
|
+
<template>
|
|
246
|
+
<slot />
|
|
247
|
+
<PageSkeleton v-if="pageLoading" />
|
|
248
|
+
</template>
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
It stays `false` on the server and during hydration, which render the page resolved, for a page that does not suspend, and once a pending page is left.
|
|
252
|
+
|
|
253
|
+
### Interface language
|
|
254
|
+
|
|
255
|
+
The interface language follows the session (`useUserSession().user.language`), not the props of the page on screen: the first render takes it from the session the page arrived with, and it changes when the session's language does, after `useUserSession().fetch()` or a visit with a newer session. A page Inertia restores from history or serves from its prefetch cache carries the session it was fetched with: the frontend server stamps every session it writes (`session.updatedAt`), and a page carrying an older stamp of the same account updates neither the session nor the language. A session without a language, such as a signed-out one, keeps the language on screen; `$i18n.setLocale()` still switches it at any time.
|
|
256
|
+
|
|
191
257
|
### Declaring the loader releases a module supports
|
|
192
258
|
|
|
193
259
|
A frontend module names the `@antelopejs/dms-frontend` releases it runs on in its own `package.json`, the way a package names the Node.js versions it supports:
|
|
@@ -412,7 +478,8 @@ The other files the workspace derives from every module follow their sources
|
|
|
412
478
|
the same way. A file added to, changed in or removed from a module's `public/`
|
|
413
479
|
is served, or no longer served, at once. Adding or removing an
|
|
414
480
|
`app/config/shortcuts-registry.ts` or a `dms.frontend.ts` regenerates the
|
|
415
|
-
aggregated shortcuts or the module loader, and the page reloads.
|
|
481
|
+
aggregated shortcuts or the module loader, and the page reloads. Adding,
|
|
482
|
+
changing or removing a `dms.frontend.build.ts` restarts Vite. A layer
|
|
416
483
|
directory added to or removed from `layers/` is the exception: Vite reads the
|
|
417
484
|
`#<layer>` aliases and the auto-imported directories at startup only, so the
|
|
418
485
|
dev server says it needs a restart.
|
|
@@ -436,6 +503,14 @@ needs no exception for the Iconify API. Icons reach the page two ways:
|
|
|
436
503
|
answers `404`, and a name outside Iconify's naming rule `400`. `ajs dms dev`
|
|
437
504
|
and `ajs dms start` run the same server, so both serve the route.
|
|
438
505
|
|
|
506
|
+
The server render draws every icon, bundled or not: the frontend server loads
|
|
507
|
+
each installed `@iconify-json/*` collection once and hands it to the renderer,
|
|
508
|
+
and an icon is drawn in its first render as soon as its data is loaded, instead
|
|
509
|
+
of after the component mounts. The data of each icon a render drew travels in
|
|
510
|
+
the page, so the browser draws the same icons as it hydrates; an icon no
|
|
511
|
+
installed collection holds stays empty on the server and is fetched in the
|
|
512
|
+
browser as before.
|
|
513
|
+
|
|
439
514
|
## Options
|
|
440
515
|
|
|
441
516
|
| Option | Environment | Purpose |
|
|
@@ -449,6 +524,7 @@ needs no exception for the Iconify API. Icons reach the page two ways:
|
|
|
449
524
|
| | `HOST` | Address the frontend server binds to (default `0.0.0.0`) |
|
|
450
525
|
| | `DMS_COOKIE_SECURE` | Secure cookies (`true` by default; `ajs dms dev` defaults to `false`) |
|
|
451
526
|
| | `DMS_TRUSTED_PROXY_HOPS` | Number of trusted, rightmost reverse-proxy hops (default `0`) |
|
|
527
|
+
| | `DMS_COUNTRY_HEADER` | Client country header the server relays to the backend, set by a trusted reverse proxy (default `CF-IPCountry`; empty relays none). Name the same header in the backend's `auth.signInCountry.header`. Relayed only when `DMS_TRUSTED_PROXY_HOPS` is above `0`, like the client address |
|
|
452
528
|
| | `DMS_SESSION_SECRET` | Session cookie encryption key, 32 characters or more (required for login) |
|
|
453
529
|
| | `DMS_HTML_RENDER_SECRET` | Secret that verifies the backend's signed HTML/email render requests; without it, e-mail renders are refused. Defaults to the `htmlRender.serviceSecret` the backend publishes in the frontend manifest |
|
|
454
530
|
| | `DMS_OAUTH_RELAY_SECRET` | Secret the server presents to the backend's OAuth endpoints; without it, the backend refuses OAuth sign-in. Defaults to the `oauth.relaySecret` the backend publishes in the frontend manifest |
|
package/dist/config.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export declare function writeSecretBearingFile(file: string, content: string): v
|
|
|
9
9
|
export declare const TEMPLATE_FILES: ReadonlyArray<readonly [string, string]>;
|
|
10
10
|
export declare const LAYERS_SUBDIR = "frontend-modules";
|
|
11
11
|
export declare const FRONTEND_MODULE_ENTRY = "dms.frontend.ts";
|
|
12
|
+
export declare const FRONTEND_BUILD_ENTRY = "dms.frontend.build.ts";
|
|
12
13
|
export declare const AUTH_ESTABLISH_FILE = "generated-auth-establish.json";
|
|
13
14
|
export declare const LAYER_COPY_BLOCKLIST: readonly string[];
|
|
14
15
|
export declare const layerCopyIgnore: ignore.Ignore;
|
package/dist/config.js
CHANGED
|
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.Options = exports.UsageError = exports.TAILWIND_SOURCE_GLOB = exports.PNPM_LIFECYCLE_SCRIPTS = exports.layerCopyIgnore = exports.LAYER_COPY_BLOCKLIST = exports.AUTH_ESTABLISH_FILE = exports.FRONTEND_MODULE_ENTRY = exports.LAYERS_SUBDIR = exports.TEMPLATE_FILES = exports.WORKSPACE_DIR_MODE = exports.DEPS_HASH_FILE = exports.DMS_FRONTEND_HOME = void 0;
|
|
6
|
+
exports.Options = exports.UsageError = exports.TAILWIND_SOURCE_GLOB = exports.PNPM_LIFECYCLE_SCRIPTS = exports.layerCopyIgnore = exports.LAYER_COPY_BLOCKLIST = exports.AUTH_ESTABLISH_FILE = exports.FRONTEND_BUILD_ENTRY = exports.FRONTEND_MODULE_ENTRY = exports.LAYERS_SUBDIR = exports.TEMPLATE_FILES = exports.WORKSPACE_DIR_MODE = exports.DEPS_HASH_FILE = exports.DMS_FRONTEND_HOME = void 0;
|
|
7
7
|
exports.writeSecretBearingFile = writeSecretBearingFile;
|
|
8
8
|
exports.parseBackendUrl = parseBackendUrl;
|
|
9
9
|
exports.requireBackendUrl = requireBackendUrl;
|
|
@@ -45,6 +45,9 @@ exports.TEMPLATE_FILES = [
|
|
|
45
45
|
["ui-app-config.ts", "ui-app-config.ts"],
|
|
46
46
|
["ssr-renderer.ts", "ssr-renderer.ts"],
|
|
47
47
|
["frontend-module.ts", "frontend-module.ts"],
|
|
48
|
+
["frontend-build.ts", "frontend-build.ts"],
|
|
49
|
+
["frontend-build-loader.ts", "frontend-build-loader.ts"],
|
|
50
|
+
["icon-hydration.ts", "icon-hydration.ts"],
|
|
48
51
|
["globals.d.ts", "globals.d.ts"],
|
|
49
52
|
["compress-assets.mjs", "compress-assets.mjs"],
|
|
50
53
|
["head-order.mjs", "head-order.mjs"],
|
|
@@ -66,6 +69,7 @@ exports.TEMPLATE_FILES = [
|
|
|
66
69
|
];
|
|
67
70
|
exports.LAYERS_SUBDIR = "frontend-modules";
|
|
68
71
|
exports.FRONTEND_MODULE_ENTRY = "dms.frontend.ts";
|
|
72
|
+
exports.FRONTEND_BUILD_ENTRY = "dms.frontend.build.ts";
|
|
69
73
|
exports.AUTH_ESTABLISH_FILE = "generated-auth-establish.json";
|
|
70
74
|
exports.LAYER_COPY_BLOCKLIST = [
|
|
71
75
|
"node_modules",
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { FrontendModuleOptions, ResolvedLayer } from "./workspace";
|
|
2
2
|
export interface FrontendModuleRegistryEntry {
|
|
3
3
|
id: string;
|
|
4
|
+
name?: string;
|
|
4
5
|
packageName?: string;
|
|
5
6
|
root: string;
|
|
6
7
|
priority: number;
|
|
7
8
|
entry?: string;
|
|
9
|
+
build?: string;
|
|
8
10
|
options: FrontendModuleOptions;
|
|
9
11
|
}
|
|
10
12
|
export interface FrontendModuleRegistry {
|
package/dist/derived-outputs.js
CHANGED
|
@@ -21,6 +21,8 @@ function validateFrontendModuleRegistry(registry, workspaceDir) {
|
|
|
21
21
|
throw new Error(`Invalid frontend module priority: ${module.id}`);
|
|
22
22
|
if (module.entry !== undefined && module.entry !== config_1.FRONTEND_MODULE_ENTRY)
|
|
23
23
|
throw new Error(`Invalid frontend module entry: ${module.id}`);
|
|
24
|
+
if (module.build !== undefined && module.build !== config_1.FRONTEND_BUILD_ENTRY)
|
|
25
|
+
throw new Error(`Invalid frontend module build entry: ${module.id}`);
|
|
24
26
|
if (!(0, node_path_1.resolve)(module.root).startsWith(moduleRoot))
|
|
25
27
|
throw new Error(`Frontend module root escapes the workspace: ${module.id}`);
|
|
26
28
|
ids.add(module.id);
|
|
@@ -29,13 +31,16 @@ function validateFrontendModuleRegistry(registry, workspaceDir) {
|
|
|
29
31
|
function createFrontendModuleRegistry(workspaceDir, layers) {
|
|
30
32
|
const modules = layers.map((layer) => {
|
|
31
33
|
const sourceEntry = (0, node_path_1.join)(layer.path, config_1.FRONTEND_MODULE_ENTRY);
|
|
34
|
+
const buildEntry = (0, node_path_1.join)(layer.path, config_1.FRONTEND_BUILD_ENTRY);
|
|
32
35
|
const options = layer.options ?? {};
|
|
33
36
|
return {
|
|
34
37
|
id: (0, layers_1.getLayerSafeName)(layer),
|
|
38
|
+
name: layer.name ?? layer.packageName,
|
|
35
39
|
packageName: layer.packageName,
|
|
36
40
|
root: (0, layers_1.getLayerWorkspacePath)(workspaceDir, layer),
|
|
37
41
|
priority: layer.priority ?? 0,
|
|
38
42
|
entry: (0, node_fs_1.existsSync)(sourceEntry) ? config_1.FRONTEND_MODULE_ENTRY : undefined,
|
|
43
|
+
build: (0, node_fs_1.existsSync)(buildEntry) ? config_1.FRONTEND_BUILD_ENTRY : undefined,
|
|
39
44
|
options: layer.configKey ? { [layer.configKey]: options } : options,
|
|
40
45
|
};
|
|
41
46
|
});
|
|
@@ -47,6 +52,7 @@ function createFrontendModuleRegistry(workspaceDir, layers) {
|
|
|
47
52
|
function writeFrontendTypePaths(workspaceDir, registry) {
|
|
48
53
|
const paths = {
|
|
49
54
|
"#dms/frontend-module": ["./frontend-module.ts"],
|
|
55
|
+
"#dms/frontend-build": ["./frontend-build.ts"],
|
|
50
56
|
"@frontend/*": ["./frontend-modules/*"],
|
|
51
57
|
...(0, layer_aliases_1.frontendLayerTypePaths)([...registry.modules].reverse().map((module) => module.root), { relativeTo: workspaceDir }),
|
|
52
58
|
};
|
|
@@ -59,14 +65,27 @@ function writeFrontendModuleLoader(workspaceDir, registry) {
|
|
|
59
65
|
.map((module, index) => `import module${index} from "@frontend/${module.id}/${module.entry}";`)
|
|
60
66
|
.join("\n");
|
|
61
67
|
const registrations = modules
|
|
62
|
-
.map((module, index) => `{ module: module${index}, options: { public: ${JSON.stringify(module.options)} } }`)
|
|
68
|
+
.map((module, index) => `{ name: ${JSON.stringify(module.name)}, module: module${index}, options: { public: ${JSON.stringify(module.options)} } }`)
|
|
63
69
|
.join(", ");
|
|
64
70
|
const content = `${imports}\n\nimport type { DmsFrontendModuleRegistration } from "./frontend-module";\n\nexport const frontendModules: DmsFrontendModuleRegistration[] = [${registrations}];\n`;
|
|
65
71
|
return (0, fs_sync_1.applyContent)(content, (0, node_path_1.join)(workspaceDir, "frontend-modules.generated.ts"));
|
|
66
72
|
}
|
|
73
|
+
function writeFrontendBuildLoader(workspaceDir, registry) {
|
|
74
|
+
const modules = registry.modules.filter((module) => module.build);
|
|
75
|
+
const imports = modules
|
|
76
|
+
.map((module, index) => `import build${index} from "./${config_1.LAYERS_SUBDIR}/${module.id}/${module.build}";`)
|
|
77
|
+
.join("\n");
|
|
78
|
+
const declarations = modules
|
|
79
|
+
.map((module, index) => `{ id: ${JSON.stringify(module.id)}, setup: build${index} }`)
|
|
80
|
+
.join(", ");
|
|
81
|
+
const content = `${imports}\n\nimport type { FrontendBuildDeclaration } from "./frontend-build-loader";\n\nexport const frontendBuilds: FrontendBuildDeclaration[] = [${declarations}];\n`;
|
|
82
|
+
return (0, fs_sync_1.applyContent)(content, (0, node_path_1.join)(workspaceDir, "frontend-builds.generated.ts"));
|
|
83
|
+
}
|
|
67
84
|
function writeModuleRegistry(workspaceDir, registry) {
|
|
68
85
|
const wroteRegistry = (0, fs_sync_1.applyContent)(`${JSON.stringify(registry, null, 2)}\n`, (0, node_path_1.join)(workspaceDir, "generated-frontend-modules.json"));
|
|
69
|
-
|
|
86
|
+
const wroteModules = writeFrontendModuleLoader(workspaceDir, registry);
|
|
87
|
+
const wroteBuilds = writeFrontendBuildLoader(workspaceDir, registry);
|
|
88
|
+
return wroteRegistry || wroteModules || wroteBuilds;
|
|
70
89
|
}
|
|
71
90
|
function moduleAssetRoots(moduleRoot) {
|
|
72
91
|
const layersRoot = (0, node_path_1.join)(moduleRoot, "layers");
|
|
@@ -278,7 +297,7 @@ function writeShortcutExports(workspaceDir, registry) {
|
|
|
278
297
|
exports.DERIVED_OUTPUTS = [
|
|
279
298
|
{
|
|
280
299
|
name: "module registry",
|
|
281
|
-
affects: (path) => (0, fs_sync_1.toPosixPath)(path)
|
|
300
|
+
affects: (path) => [config_1.FRONTEND_MODULE_ENTRY, config_1.FRONTEND_BUILD_ENTRY].includes((0, fs_sync_1.toPosixPath)(path)),
|
|
282
301
|
write: writeModuleRegistry,
|
|
283
302
|
},
|
|
284
303
|
{
|
package/dist/help.js
CHANGED
|
@@ -25,6 +25,10 @@ exports.ENVIRONMENT_VARIABLES = [
|
|
|
25
25
|
"DMS_TRUSTED_PROXY_HOPS",
|
|
26
26
|
"Trusted reverse-proxy hops, counted from the right (default: 0)",
|
|
27
27
|
],
|
|
28
|
+
[
|
|
29
|
+
"DMS_COUNTRY_HEADER",
|
|
30
|
+
"Proxy country header relayed to the backend when a proxy is trusted (default: CF-IPCountry; empty: none)",
|
|
31
|
+
],
|
|
28
32
|
[
|
|
29
33
|
"DMS_HTML_RENDER_SECRET",
|
|
30
34
|
"Overrides the e-mail render secret the backend publishes",
|
package/dist/layers.js
CHANGED
package/dist/workspace.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -6,6 +6,7 @@ import { en as uiEn, fr as uiFr } from "@nuxt/ui/locale";
|
|
|
6
6
|
import ui from "@nuxt/ui/vue-plugin";
|
|
7
7
|
import { useHead } from "@unhead/vue";
|
|
8
8
|
import type { VueHeadClient } from "@unhead/vue/types";
|
|
9
|
+
import type { IconifyIcon } from "@iconify/vue";
|
|
9
10
|
import type { FetchOptions } from "ofetch";
|
|
10
11
|
import {
|
|
11
12
|
type App,
|
|
@@ -23,7 +24,9 @@ import DmsDynamicPage from "./DmsDynamicPage.vue";
|
|
|
23
24
|
import {
|
|
24
25
|
type DmsLocaleLoader,
|
|
25
26
|
type DmsPageProps,
|
|
27
|
+
type DmsUser,
|
|
26
28
|
createDmsFrontendRuntime,
|
|
29
|
+
dmsPageSuspenseProps,
|
|
27
30
|
getDmsErrorPage,
|
|
28
31
|
getDmsLayout,
|
|
29
32
|
getDmsLayoutProps,
|
|
@@ -39,7 +42,13 @@ import {
|
|
|
39
42
|
useDmsAppConfig,
|
|
40
43
|
useDmsState,
|
|
41
44
|
useError,
|
|
45
|
+
useUserSession,
|
|
42
46
|
} from "./frontend-module";
|
|
47
|
+
import {
|
|
48
|
+
SSR_ICONS_ID,
|
|
49
|
+
addDmsIcons,
|
|
50
|
+
drawLoadedIconsAtOnce,
|
|
51
|
+
} from "./icon-hydration";
|
|
43
52
|
import {
|
|
44
53
|
loadLocaleMessages,
|
|
45
54
|
localeMessages,
|
|
@@ -94,30 +103,24 @@ function uiLocale(locale: string): UiLocale {
|
|
|
94
103
|
return UI_LOCALES[locale as keyof typeof UI_LOCALES] ?? UI_LOCALES.en;
|
|
95
104
|
}
|
|
96
105
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
106
|
+
/** The supported locale of a user's language, if it has one. */
|
|
107
|
+
function userLocale(user: DmsUser | null | undefined): string | undefined {
|
|
108
|
+
const language = user?.language;
|
|
109
|
+
if (typeof language !== "string") return undefined;
|
|
100
110
|
const locale = language.slice(0, 2);
|
|
101
|
-
return supportedLocales.includes(locale) ? locale :
|
|
111
|
+
return supportedLocales.includes(locale) ? locale : undefined;
|
|
102
112
|
}
|
|
103
113
|
|
|
104
|
-
function
|
|
105
|
-
if (typeof document === "undefined") return
|
|
106
|
-
const element = document.getElementById(
|
|
107
|
-
if (!element?.textContent) return
|
|
108
|
-
const
|
|
114
|
+
function readServerRendered<T>(id: string, empty: T): T {
|
|
115
|
+
if (typeof document === "undefined") return empty;
|
|
116
|
+
const element = document.getElementById(id);
|
|
117
|
+
if (!element?.textContent) return empty;
|
|
118
|
+
const value = JSON.parse(element.textContent) as T;
|
|
109
119
|
element.remove();
|
|
110
|
-
return
|
|
120
|
+
return value;
|
|
111
121
|
}
|
|
112
122
|
|
|
113
|
-
|
|
114
|
-
if (typeof document === "undefined") return [];
|
|
115
|
-
const element = document.getElementById(SSR_ASYNC_COMPONENTS_ID);
|
|
116
|
-
if (!element?.textContent) return [];
|
|
117
|
-
const names = JSON.parse(element.textContent) as string[];
|
|
118
|
-
element.remove();
|
|
119
|
-
return names;
|
|
120
|
-
}
|
|
123
|
+
drawLoadedIconsAtOnce();
|
|
121
124
|
|
|
122
125
|
const DmsInertiaPage = defineComponent({
|
|
123
126
|
name: "DmsInertiaPage",
|
|
@@ -148,17 +151,25 @@ const DmsPersistentLayout = defineComponent({
|
|
|
148
151
|
setup(_, { attrs, slots }) {
|
|
149
152
|
const props = computed(() => attrs as unknown as DmsPageProps);
|
|
150
153
|
const i18n = useI18n();
|
|
154
|
+
const { user } = useUserSession();
|
|
151
155
|
let localeVersion = 0;
|
|
152
156
|
useHead({
|
|
153
157
|
htmlAttrs: { lang: computed(() => i18n.locale.value) },
|
|
154
158
|
});
|
|
155
159
|
watch(
|
|
156
160
|
() => [attrs.path, attrs.page, attrs.user, attrs.session, attrs.error],
|
|
157
|
-
|
|
161
|
+
() => hydrateDmsPageProps(props.value),
|
|
162
|
+
{ immediate: true, flush: "sync" },
|
|
163
|
+
);
|
|
164
|
+
// The session, not the page props: a page restored from history or from
|
|
165
|
+
// the prefetch cache carries the user as it was when it was fetched, and
|
|
166
|
+
// would switch the interface back to a language the user has left. A
|
|
167
|
+
// session without a language keeps the one on screen.
|
|
168
|
+
watch(
|
|
169
|
+
() => userLocale(user.value),
|
|
170
|
+
async (locale) => {
|
|
158
171
|
const version = ++localeVersion;
|
|
159
|
-
|
|
160
|
-
const locale = pageLocale(props.value);
|
|
161
|
-
if (i18n.locale.value === locale) return;
|
|
172
|
+
if (!locale || i18n.locale.value === locale) return;
|
|
162
173
|
const messages = await loadLocaleMessages(locale);
|
|
163
174
|
if (version !== localeVersion) return;
|
|
164
175
|
i18n.setLocaleMessage(locale, messages);
|
|
@@ -205,7 +216,7 @@ function renderDmsPage(
|
|
|
205
216
|
error,
|
|
206
217
|
key: props.path,
|
|
207
218
|
});
|
|
208
|
-
return h(Suspense,
|
|
219
|
+
return h(Suspense, dmsPageSuspenseProps(), { default: () => pageContent });
|
|
209
220
|
}
|
|
210
221
|
|
|
211
222
|
function renderDmsPersistentLayout(
|
|
@@ -247,9 +258,9 @@ export async function configureDmsApp(
|
|
|
247
258
|
options.runtime ??
|
|
248
259
|
createDmsFrontendRuntime(
|
|
249
260
|
options.serverFetch as typeof import("ofetch").ofetch,
|
|
250
|
-
|
|
261
|
+
readServerRendered<Record<string, unknown>>(SSR_ASYNC_DATA_ID, {}),
|
|
251
262
|
);
|
|
252
|
-
const locale =
|
|
263
|
+
const locale = userLocale(options.initialPageProps.user) ?? DEFAULT_LOCALE;
|
|
253
264
|
const messages = await loadLocaleMessages(locale);
|
|
254
265
|
const i18n = createI18n({
|
|
255
266
|
legacy: false,
|
|
@@ -266,6 +277,9 @@ export async function configureDmsApp(
|
|
|
266
277
|
options.app.runWithContext(() =>
|
|
267
278
|
hydrateDmsPageProps(options.initialPageProps, options.initialPageUrl),
|
|
268
279
|
);
|
|
280
|
+
addDmsIcons(
|
|
281
|
+
readServerRendered<Record<string, IconifyIcon>>(SSR_ICONS_ID, {}),
|
|
282
|
+
);
|
|
269
283
|
if (typeof window !== "undefined") {
|
|
270
284
|
const toast = options.app.runWithContext(() => useToast());
|
|
271
285
|
showNetworkErrors(toast.add, i18n.global);
|
|
@@ -275,7 +289,9 @@ export async function configureDmsApp(
|
|
|
275
289
|
// Hydration then adopts the server-rendered markup instead of discarding it.
|
|
276
290
|
await Promise.all([
|
|
277
291
|
preloadDmsPage(options.initialPageProps),
|
|
278
|
-
resolveDmsAsyncComponents(
|
|
292
|
+
resolveDmsAsyncComponents(
|
|
293
|
+
readServerRendered<string[]>(SSR_ASYNC_COMPONENTS_ID, []),
|
|
294
|
+
),
|
|
279
295
|
]);
|
|
280
296
|
const mounted = await installDmsPlugins(
|
|
281
297
|
options.app,
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { isAbsolute, posix, resolve } from "node:path";
|
|
2
|
+
import { normalizePath } from "vite";
|
|
3
|
+
import type { DmsFrontendBuildSetup } from "./frontend-build";
|
|
4
|
+
|
|
5
|
+
/** A module's `dms.frontend.build.ts`, as the generated loader imports it. */
|
|
6
|
+
export interface FrontendBuildDeclaration {
|
|
7
|
+
/** The module's id in the generated module registry. */
|
|
8
|
+
id: string;
|
|
9
|
+
setup: DmsFrontendBuildSetup;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface FrontendModuleRoot {
|
|
13
|
+
id: string;
|
|
14
|
+
root: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AutoImportDirectory {
|
|
18
|
+
glob: string;
|
|
19
|
+
types: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A module's private code: never auto-imported, whatever it declares. */
|
|
23
|
+
const PRIVATE_DIRECTORY = /(?:^|\/)app\/build(?:\/|$)/;
|
|
24
|
+
|
|
25
|
+
function declaredDirectories(setup: DmsFrontendBuildSetup): string[] {
|
|
26
|
+
const directories: string[] = [];
|
|
27
|
+
setup({
|
|
28
|
+
registerAutoImports: (entries) => directories.push(...entries),
|
|
29
|
+
});
|
|
30
|
+
return directories;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function rejection(directory: string): string | undefined {
|
|
34
|
+
const path = posix.normalize(normalizePath(directory));
|
|
35
|
+
if (isAbsolute(directory) || path === ".." || path.startsWith("../"))
|
|
36
|
+
return "it is not inside the module";
|
|
37
|
+
if (PRIVATE_DIRECTORY.test(path))
|
|
38
|
+
return "app/build/ holds the module's private code, which is never auto-imported";
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The directories the modules declared in their `dms.frontend.build.ts`, as
|
|
44
|
+
* the auto-importer's scan globs. Each module's `app/build/` directories are
|
|
45
|
+
* excluded even from a broader declaration; a declaration naming one, or
|
|
46
|
+
* leaving the module, is ignored with a warning.
|
|
47
|
+
*
|
|
48
|
+
* Globs are POSIX on every platform: the scanner reads a backslash as an
|
|
49
|
+
* escape, not as a separator.
|
|
50
|
+
*/
|
|
51
|
+
export function autoImportDirectories(
|
|
52
|
+
modules: readonly FrontendModuleRoot[],
|
|
53
|
+
declarations: readonly FrontendBuildDeclaration[],
|
|
54
|
+
warn: (message: string) => void,
|
|
55
|
+
): AutoImportDirectory[] {
|
|
56
|
+
const roots = new Map(modules.map((module) => [module.id, module.root]));
|
|
57
|
+
return declarations.flatMap(({ id, setup }) => {
|
|
58
|
+
const root = roots.get(id);
|
|
59
|
+
if (root === undefined) return [];
|
|
60
|
+
const scan = (pattern: string) => normalizePath(resolve(root, pattern));
|
|
61
|
+
const accepted = declaredDirectories(setup).filter((directory) => {
|
|
62
|
+
const reason = rejection(directory);
|
|
63
|
+
if (reason)
|
|
64
|
+
warn(
|
|
65
|
+
`Frontend module ${id} declares "${directory}" for auto-import, which is ignored: ${reason}.`,
|
|
66
|
+
);
|
|
67
|
+
return !reason;
|
|
68
|
+
});
|
|
69
|
+
if (accepted.length === 0) return [];
|
|
70
|
+
return [
|
|
71
|
+
...accepted.map((directory) => ({
|
|
72
|
+
glob: scan(`${directory}/**/*`),
|
|
73
|
+
types: true,
|
|
74
|
+
})),
|
|
75
|
+
{ glob: `!${scan("**/app/build/**")}`, types: true },
|
|
76
|
+
];
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a frontend module's `dms.frontend.build.ts` declares about how the
|
|
3
|
+
* application is built from it. Read when Vite starts, by `ajs dms dev` and
|
|
4
|
+
* `ajs dms build` alike.
|
|
5
|
+
*/
|
|
6
|
+
export interface DmsFrontendBuild {
|
|
7
|
+
/**
|
|
8
|
+
* Makes every export of the files under these directories available to
|
|
9
|
+
* the whole application without an import, in scripts and templates.
|
|
10
|
+
*
|
|
11
|
+
* Each entry is a directory relative to the module root, and may hold glob
|
|
12
|
+
* patterns: `layers/*\/app/composables` covers that directory of every
|
|
13
|
+
* layer. Nothing under an `app/build/` directory is ever auto-imported: it
|
|
14
|
+
* holds the module's private code, which its own files import by path.
|
|
15
|
+
*/
|
|
16
|
+
registerAutoImports(directories: readonly string[]): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type DmsFrontendBuildSetup = (build: DmsFrontendBuild) => void;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Declares how the application is built from a frontend module, as the
|
|
23
|
+
* default export of its `dms.frontend.build.ts`:
|
|
24
|
+
*
|
|
25
|
+
* ```ts
|
|
26
|
+
* import { defineDmsFrontendBuild } from "#dms/frontend-build";
|
|
27
|
+
*
|
|
28
|
+
* export default defineDmsFrontendBuild((build) => {
|
|
29
|
+
* build.registerAutoImports(["layers/*\/app/composables"]);
|
|
30
|
+
* });
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* A module without this file has nothing auto-imported. The file runs in
|
|
34
|
+
* Node.js when Vite starts, not in the application: it may import Node.js
|
|
35
|
+
* built-ins, never the application's own code.
|
|
36
|
+
*/
|
|
37
|
+
export function defineDmsFrontendBuild(
|
|
38
|
+
setup: DmsFrontendBuildSetup,
|
|
39
|
+
): DmsFrontendBuildSetup {
|
|
40
|
+
return setup;
|
|
41
|
+
}
|
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
type Component,
|
|
16
16
|
type ComponentPublicInstance,
|
|
17
17
|
type ComputedRef,
|
|
18
|
+
camelize,
|
|
19
|
+
capitalize,
|
|
18
20
|
computed,
|
|
19
21
|
defineComponent,
|
|
20
22
|
type EffectScope,
|
|
@@ -31,6 +33,7 @@ import {
|
|
|
31
33
|
type PropType,
|
|
32
34
|
type Ref,
|
|
33
35
|
reactive,
|
|
36
|
+
readonly,
|
|
34
37
|
ref,
|
|
35
38
|
watch,
|
|
36
39
|
} from "vue";
|
|
@@ -82,6 +85,13 @@ export interface DmsUser extends Record<string, unknown> {}
|
|
|
82
85
|
export interface DmsSession {
|
|
83
86
|
accountId: string;
|
|
84
87
|
activeTenantId?: string;
|
|
88
|
+
/**
|
|
89
|
+
* When the frontend server last wrote this session, in milliseconds since
|
|
90
|
+
* the epoch. A page restored from history or from the prefetch cache
|
|
91
|
+
* carries an older session than the one the app already holds, and does not
|
|
92
|
+
* replace it.
|
|
93
|
+
*/
|
|
94
|
+
updatedAt?: number;
|
|
85
95
|
}
|
|
86
96
|
export interface DmsRuntimeConfig extends RuntimeConfig {
|
|
87
97
|
public: PublicRuntimeConfig;
|
|
@@ -220,9 +230,21 @@ export interface DmsPluginRegistrationOptions {
|
|
|
220
230
|
clientOnly?: boolean;
|
|
221
231
|
}
|
|
222
232
|
export type DmsComponentPreloader = () => Promise<unknown>;
|
|
233
|
+
export interface DmsComponentRegistrationOptions {
|
|
234
|
+
/**
|
|
235
|
+
* Registers the component under `name` as given, without the module's
|
|
236
|
+
* `componentPrefix`: the way to replace a component another module
|
|
237
|
+
* registers, from a module of higher priority. Defaults to `true`.
|
|
238
|
+
*/
|
|
239
|
+
prefix?: boolean;
|
|
240
|
+
}
|
|
223
241
|
export interface DmsFrontendSdk {
|
|
224
242
|
options: DmsModuleOptions;
|
|
225
|
-
registerComponent(
|
|
243
|
+
registerComponent(
|
|
244
|
+
name: string,
|
|
245
|
+
component: Component,
|
|
246
|
+
options?: DmsComponentRegistrationOptions,
|
|
247
|
+
): void;
|
|
226
248
|
registerPage(
|
|
227
249
|
name: string,
|
|
228
250
|
component: Component,
|
|
@@ -261,9 +283,18 @@ export interface DmsFrontendSdk {
|
|
|
261
283
|
use(plugin: Plugin): void;
|
|
262
284
|
}
|
|
263
285
|
export interface DmsFrontendModule {
|
|
286
|
+
/**
|
|
287
|
+
* Put in front of the name of every component the module registers, so
|
|
288
|
+
* the components of two modules never collide: with `"Acme"`,
|
|
289
|
+
* `registerComponent("Banner", ...)` registers `AcmeBanner`, the name the
|
|
290
|
+
* backend sends and templates use.
|
|
291
|
+
*/
|
|
292
|
+
componentPrefix?: string;
|
|
264
293
|
setup(sdk: DmsFrontendSdk): void | Promise<void>;
|
|
265
294
|
}
|
|
266
295
|
export interface DmsFrontendModuleRegistration {
|
|
296
|
+
/** The module's manifest name, which collision warnings name it by. */
|
|
297
|
+
name?: string;
|
|
267
298
|
module: DmsFrontendModule;
|
|
268
299
|
options: DmsModuleOptions;
|
|
269
300
|
}
|
|
@@ -283,6 +314,10 @@ export interface DmsRuntimeHooks {
|
|
|
283
314
|
interface DmsComponentRegistration {
|
|
284
315
|
name: string;
|
|
285
316
|
component: Component;
|
|
317
|
+
/** The registering module, for a collision warning. */
|
|
318
|
+
module?: string;
|
|
319
|
+
/** Registered without the module's prefix, to replace another module's. */
|
|
320
|
+
unprefixed?: boolean;
|
|
286
321
|
}
|
|
287
322
|
|
|
288
323
|
interface DmsFrontendEntry {
|
|
@@ -333,6 +368,8 @@ export interface DmsFrontendRuntime {
|
|
|
333
368
|
pendingNavigation?: Promise<void>;
|
|
334
369
|
hasNavigationListener: boolean;
|
|
335
370
|
pageVersion: number;
|
|
371
|
+
/** Whether the page `<Suspense>` waits on the page being navigated to. */
|
|
372
|
+
pageLoading: Ref<boolean>;
|
|
336
373
|
/**
|
|
337
374
|
* Identifies the page on screen (its path, or the error page shown at that
|
|
338
375
|
* path), whose meta `route.meta` holds.
|
|
@@ -403,6 +440,7 @@ export function createDmsFrontendRuntime(
|
|
|
403
440
|
}),
|
|
404
441
|
hasNavigationListener: false,
|
|
405
442
|
pageVersion: 0,
|
|
443
|
+
pageLoading: ref(false),
|
|
406
444
|
scope: effectScope(true),
|
|
407
445
|
};
|
|
408
446
|
}
|
|
@@ -1187,20 +1225,86 @@ export function useUserSession<
|
|
|
1187
1225
|
};
|
|
1188
1226
|
}
|
|
1189
1227
|
|
|
1228
|
+
/**
|
|
1229
|
+
* Whether the page loaded by the current navigation is still pending: its
|
|
1230
|
+
* chunk, or an async `setup` it awaits, has not resolved yet. A layout reads
|
|
1231
|
+
* it to show its own placeholder in place of the page. Always `false` on the
|
|
1232
|
+
* server and during hydration, which render the page resolved.
|
|
1233
|
+
*/
|
|
1234
|
+
export function useDmsPageLoading(): Readonly<Ref<boolean>> {
|
|
1235
|
+
return readonly(useDmsRuntime().pageLoading);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* The props of the `<Suspense>` around the page that keep `useDmsPageLoading`
|
|
1240
|
+
* in step with it. Cleared before the boundary unmounts, so a page left while
|
|
1241
|
+
* pending never leaves the state set.
|
|
1242
|
+
*/
|
|
1243
|
+
export function dmsPageSuspenseProps(): Record<string, () => void> {
|
|
1244
|
+
const loading = useDmsRuntime().pageLoading;
|
|
1245
|
+
const settle = () => {
|
|
1246
|
+
loading.value = false;
|
|
1247
|
+
};
|
|
1248
|
+
return {
|
|
1249
|
+
onPending: () => {
|
|
1250
|
+
loading.value = true;
|
|
1251
|
+
},
|
|
1252
|
+
onResolve: settle,
|
|
1253
|
+
onVnodeBeforeUnmount: settle,
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/**
|
|
1258
|
+
* The key a component name is registered and resolved under: case, a leading
|
|
1259
|
+
* `lazy` and every non-alphanumeric character ignored, so PascalCase,
|
|
1260
|
+
* kebab-case and lowercase spellings match. Unlike `normalizeDmsName`, a
|
|
1261
|
+
* leading `Dms` is kept: it is the DMS module's prefix, and dropping it would
|
|
1262
|
+
* make `DmsBanner` and another module's `Banner` the same component.
|
|
1263
|
+
*/
|
|
1264
|
+
function normalizeDmsComponentName(name: string): string {
|
|
1265
|
+
return name
|
|
1266
|
+
.replace(/^lazy/i, "")
|
|
1267
|
+
.replace(/[^a-z0-9]/gi, "")
|
|
1268
|
+
.toLowerCase();
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
function moduleLabel(module: string | undefined): string {
|
|
1272
|
+
return module ? `module "${module}"` : "an unnamed module";
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/**
|
|
1276
|
+
* A component registered under a name already taken is ignored: the first
|
|
1277
|
+
* registration, from the module of higher priority, keeps the name. That is
|
|
1278
|
+
* how a module replaces another's component, by registering its full name
|
|
1279
|
+
* without a prefix, and only a collision neither side asked for is reported.
|
|
1280
|
+
*/
|
|
1190
1281
|
function registerDmsComponentEntry(
|
|
1191
1282
|
entries: Map<string, DmsComponentRegistration>,
|
|
1192
|
-
|
|
1193
|
-
component: Component,
|
|
1283
|
+
registration: DmsComponentRegistration,
|
|
1194
1284
|
): void {
|
|
1195
|
-
const key =
|
|
1196
|
-
|
|
1285
|
+
const key = normalizeDmsComponentName(registration.name);
|
|
1286
|
+
const existing = entries.get(key);
|
|
1287
|
+
if (!existing) {
|
|
1288
|
+
entries.set(key, registration);
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
if (existing.unprefixed || registration.unprefixed) return;
|
|
1292
|
+
console.warn(
|
|
1293
|
+
`[dms] Component "${registration.name}" from ${moduleLabel(registration.module)} is ignored: ` +
|
|
1294
|
+
`${moduleLabel(existing.module)} already registers "${existing.name}". ` +
|
|
1295
|
+
"Give the modules different component prefixes.",
|
|
1296
|
+
);
|
|
1197
1297
|
}
|
|
1198
1298
|
/** Registers a component unless a higher-priority module already owns its name. */
|
|
1199
1299
|
export function registerDmsComponent(name: string, component: Component): void {
|
|
1200
|
-
registerDmsComponentEntry(registry.components, name, component);
|
|
1300
|
+
registerDmsComponentEntry(registry.components, { name, component });
|
|
1201
1301
|
}
|
|
1302
|
+
/**
|
|
1303
|
+
* Resolves a registered component by its full name, prefix included. Case,
|
|
1304
|
+
* a leading `lazy` and non-alphanumeric characters are ignored.
|
|
1305
|
+
*/
|
|
1202
1306
|
export function resolveDmsComponent(name: string): Component | undefined {
|
|
1203
|
-
return registry.components.get(
|
|
1307
|
+
return registry.components.get(normalizeDmsComponentName(name))?.component;
|
|
1204
1308
|
}
|
|
1205
1309
|
export const getDmsComponent = resolveDmsComponent;
|
|
1206
1310
|
|
|
@@ -1371,6 +1475,24 @@ export function getDmsLayoutProps(
|
|
|
1371
1475
|
};
|
|
1372
1476
|
}
|
|
1373
1477
|
|
|
1478
|
+
/**
|
|
1479
|
+
* Whether a page carries an older session of the same account than the one
|
|
1480
|
+
* the app holds: Inertia restores a page from history, or serves it from the
|
|
1481
|
+
* prefetch cache, with the props it was fetched with, and those must not undo
|
|
1482
|
+
* a later `useUserSession().fetch()`.
|
|
1483
|
+
*/
|
|
1484
|
+
function isStaleSession(
|
|
1485
|
+
incoming: DmsSession | undefined,
|
|
1486
|
+
current: DmsSession | null,
|
|
1487
|
+
): boolean {
|
|
1488
|
+
return (
|
|
1489
|
+
!!incoming &&
|
|
1490
|
+
!!current &&
|
|
1491
|
+
incoming.accountId === current.accountId &&
|
|
1492
|
+
(incoming.updatedAt ?? 0) < (current.updatedAt ?? 0)
|
|
1493
|
+
);
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1374
1496
|
export function hydrateDmsPageProps(props: DmsPageProps, url?: string): void {
|
|
1375
1497
|
const runtime = useDmsRuntime();
|
|
1376
1498
|
if (url) updateRoute(runtime, url);
|
|
@@ -1384,10 +1506,12 @@ export function hydrateDmsPageProps(props: DmsPageProps, url?: string): void {
|
|
|
1384
1506
|
}
|
|
1385
1507
|
runtime.pageVersion++;
|
|
1386
1508
|
runtime.currentError.value = null;
|
|
1387
|
-
useDmsState<
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1509
|
+
const session = useDmsState<DmsSession | null>("dms-session", () => null);
|
|
1510
|
+
if (!isStaleSession(props.session, session.value)) {
|
|
1511
|
+
useDmsState<DmsUser | null>("dms-user", () => null).value =
|
|
1512
|
+
props.user ?? null;
|
|
1513
|
+
session.value = props.session ?? null;
|
|
1514
|
+
}
|
|
1391
1515
|
const shared = props.page.shared;
|
|
1392
1516
|
if (shared) {
|
|
1393
1517
|
useDmsState<unknown>("dms-siteLayout", () => undefined).value =
|
|
@@ -1451,14 +1575,28 @@ function registerDmsFrontendEntry(
|
|
|
1451
1575
|
if (!entries.has(name)) entries.set(name, { component, preload });
|
|
1452
1576
|
}
|
|
1453
1577
|
|
|
1578
|
+
/** `name` behind `prefix`, as a PascalCase component name. */
|
|
1579
|
+
function prefixedComponentName(prefix: string, name: string): string {
|
|
1580
|
+
return prefix ? `${prefix}${capitalize(camelize(name))}` : name;
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1454
1583
|
function createSdk(
|
|
1455
1584
|
target: DmsFrontendRegistry,
|
|
1456
|
-
|
|
1585
|
+
{
|
|
1586
|
+
name: module,
|
|
1587
|
+
module: { componentPrefix = "" },
|
|
1588
|
+
options,
|
|
1589
|
+
}: DmsFrontendModuleRegistration,
|
|
1457
1590
|
): DmsFrontendSdk {
|
|
1458
1591
|
return {
|
|
1459
1592
|
options,
|
|
1460
|
-
registerComponent: (name, component) =>
|
|
1461
|
-
registerDmsComponentEntry(target.components,
|
|
1593
|
+
registerComponent: (name, component, { prefix = true } = {}) =>
|
|
1594
|
+
registerDmsComponentEntry(target.components, {
|
|
1595
|
+
name: prefix ? prefixedComponentName(componentPrefix, name) : name,
|
|
1596
|
+
component,
|
|
1597
|
+
module,
|
|
1598
|
+
unprefixed: !prefix,
|
|
1599
|
+
}),
|
|
1462
1600
|
registerPage: (name, component, preload) => {
|
|
1463
1601
|
const key = normalizeDmsPageKey(name);
|
|
1464
1602
|
registerDmsFrontendEntry(target.pages, key, component, preload);
|
|
@@ -1519,7 +1657,7 @@ export async function setupFrontendModules(
|
|
|
1519
1657
|
runtimeConfig.value.public,
|
|
1520
1658
|
registration.options.public,
|
|
1521
1659
|
);
|
|
1522
|
-
await registration.module.setup(createSdk(next, registration
|
|
1660
|
+
await registration.module.setup(createSdk(next, registration));
|
|
1523
1661
|
}
|
|
1524
1662
|
registry = next;
|
|
1525
1663
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import {
|
|
2
|
+
addCollection,
|
|
3
|
+
addIcon,
|
|
4
|
+
getIcon,
|
|
5
|
+
Icon,
|
|
6
|
+
type IconifyIcon,
|
|
7
|
+
type IconifyJSON,
|
|
8
|
+
iconLoaded,
|
|
9
|
+
} from "@iconify/vue";
|
|
10
|
+
import type { App, Component, ComponentPublicInstance } from "vue";
|
|
11
|
+
|
|
12
|
+
/** The element a server render writes the data of the icons it drew to. */
|
|
13
|
+
export const SSR_ICONS_ID = "dms-ssr-icons";
|
|
14
|
+
|
|
15
|
+
interface IconProps {
|
|
16
|
+
icon?: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Draws an Iconify icon in its first render whenever its data is loaded, on
|
|
21
|
+
* the server as in the browser.
|
|
22
|
+
*
|
|
23
|
+
* `@iconify/vue` otherwise renders every icon empty until it mounts, which a
|
|
24
|
+
* server render never does: pages arrived without their icons, which popped in
|
|
25
|
+
* once hydrated. Its `ssr` prop opts out of the wait, but Nuxt UI's `UIcon`
|
|
26
|
+
* does not pass it, so its default is set here, and only for an icon whose
|
|
27
|
+
* data is loaded: an icon still to be fetched waits for the mount as before,
|
|
28
|
+
* so a server render never queries an Iconify API.
|
|
29
|
+
*/
|
|
30
|
+
export function drawLoadedIconsAtOnce(): void {
|
|
31
|
+
(Icon as unknown as { props: Record<string, unknown> }).props.ssr = {
|
|
32
|
+
type: Boolean,
|
|
33
|
+
default: ({ icon }: IconProps) =>
|
|
34
|
+
typeof icon !== "string" || iconLoaded(icon),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Loads whole icon collections, so a server render draws any icon of them,
|
|
40
|
+
* including those only named by backend data, which the client bundle's scan
|
|
41
|
+
* cannot see.
|
|
42
|
+
*/
|
|
43
|
+
export function registerDmsIconCollections(collections: IconifyJSON[]): void {
|
|
44
|
+
for (const collection of collections) addCollection(collection);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Records the name of every Iconify icon a server render reaches. */
|
|
48
|
+
export function trackDmsIcons(app: App): Set<string> {
|
|
49
|
+
const names = new Set<string>();
|
|
50
|
+
app.mixin({
|
|
51
|
+
beforeCreate(this: ComponentPublicInstance) {
|
|
52
|
+
if (this.$.type !== (Icon as Component)) return;
|
|
53
|
+
const { icon } = this.$props as IconProps;
|
|
54
|
+
if (typeof icon === "string") names.add(icon);
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
return names;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The data of the icons a server render drew, which the browser loads before
|
|
62
|
+
* it hydrates: an icon outside the client bundle would otherwise hydrate
|
|
63
|
+
* empty over the server's markup.
|
|
64
|
+
*/
|
|
65
|
+
export function serializeDmsIcons(
|
|
66
|
+
names: Iterable<string>,
|
|
67
|
+
): Record<string, IconifyIcon> {
|
|
68
|
+
return Object.fromEntries(
|
|
69
|
+
[...names].flatMap((name) => {
|
|
70
|
+
const data = getIcon(name);
|
|
71
|
+
return data ? [[name, data]] : [];
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function addDmsIcons(icons: Record<string, IconifyIcon>): void {
|
|
77
|
+
for (const [name, data] of Object.entries(icons)) addIcon(name, data);
|
|
78
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { clientIp } from "./client-ip.mjs";
|
|
1
|
+
import { clientCountryHeader, clientIp } from "./client-ip.mjs";
|
|
2
2
|
|
|
3
3
|
const FORWARDED_HEADERS = [
|
|
4
4
|
"user-agent",
|
|
@@ -26,12 +26,26 @@ export function publicBackendMessage(data) {
|
|
|
26
26
|
: undefined;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
/**
|
|
30
|
+
* The headers of a client request the server relays to the backend.
|
|
31
|
+
*
|
|
32
|
+
* @param request The client request
|
|
33
|
+
* @param names Lowercase names of the request headers to copy when present
|
|
34
|
+
* @returns The client country header, then the copied headers
|
|
35
|
+
*/
|
|
36
|
+
export function relayedHeaders(request, names) {
|
|
37
|
+
return {
|
|
38
|
+
...clientCountryHeader(request),
|
|
39
|
+
...Object.fromEntries(
|
|
40
|
+
names.flatMap((name) =>
|
|
41
|
+
request.headers[name] ? [[name, request.headers[name]]] : [],
|
|
42
|
+
),
|
|
33
43
|
),
|
|
34
|
-
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function backend(path, request, options = {}) {
|
|
48
|
+
const headers = relayedHeaders(request, FORWARDED_HEADERS);
|
|
35
49
|
headers["x-forwarded-for"] = clientIp(request);
|
|
36
50
|
if (options.token) headers.authorization = `Bearer ${options.token}`;
|
|
37
51
|
if (options.relay)
|
|
@@ -51,6 +51,34 @@ export function clientIp(request) {
|
|
|
51
51
|
return chain[Math.max(0, chain.length - hops - 1)] ?? socketIp;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
const DEFAULT_COUNTRY_HEADER = "CF-IPCountry";
|
|
55
|
+
const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9a-z-]+$/;
|
|
56
|
+
|
|
57
|
+
function countryHeader() {
|
|
58
|
+
const name = (process.env.DMS_COUNTRY_HEADER ?? DEFAULT_COUNTRY_HEADER)
|
|
59
|
+
.trim()
|
|
60
|
+
.toLowerCase();
|
|
61
|
+
return HEADER_NAME.test(name) ? name : undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The client's country header, as the backend request headers to add.
|
|
66
|
+
*
|
|
67
|
+
* The header is the one `DMS_COUNTRY_HEADER` names (`CF-IPCountry` by
|
|
68
|
+
* default, empty to relay none), the name the DMS reads from
|
|
69
|
+
* `auth.signInCountry.header`. Like `X-Forwarded-For`, it is only believed
|
|
70
|
+
* from a trusted reverse proxy: with `DMS_TRUSTED_PROXY_HOPS` at `0`, any
|
|
71
|
+
* caller could have set it, so nothing is relayed.
|
|
72
|
+
*/
|
|
73
|
+
export function clientCountryHeader(request) {
|
|
74
|
+
const name = countryHeader();
|
|
75
|
+
if (!name || !trustedHops()) return {};
|
|
76
|
+
const value = request.headers?.[name];
|
|
77
|
+
return typeof value === "string" && value.trim()
|
|
78
|
+
? { [name]: value.trim() }
|
|
79
|
+
: {};
|
|
80
|
+
}
|
|
81
|
+
|
|
54
82
|
/**
|
|
55
83
|
* Body answered with the 403 of the CSRF origin check.
|
|
56
84
|
*
|
|
@@ -146,9 +146,15 @@ export function assertAccountInvariant(session, accountId) {
|
|
|
146
146
|
throw new Error("Account session invariant violated");
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Write `session` as the current one, and as one of the stored accounts.
|
|
151
|
+
* Stamps it with `updatedAt`, which lets the browser tell a page fetched
|
|
152
|
+
* before this write, and restored from its cache, from a current one.
|
|
153
|
+
*/
|
|
149
154
|
export function persistAccountSession(request, response, session, accountId) {
|
|
150
155
|
const id = accountId ?? session.accountId ?? randomUUID();
|
|
151
156
|
session.accountId = id;
|
|
157
|
+
session.updatedAt = Date.now();
|
|
152
158
|
const account = storeAccount(request, response, session, id);
|
|
153
159
|
assertAccountInvariant(session, account.accountId);
|
|
154
160
|
writeSession(response, session);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
@@ -14,12 +14,16 @@ const READ_METHODS = new Set(["GET", "HEAD"]);
|
|
|
14
14
|
const ICON_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
15
15
|
const COLLECTION_FILE = /^([^/]+)\.json$/;
|
|
16
16
|
const ICON_NAME_SEPARATOR = ",";
|
|
17
|
+
const COLLECTION_PACKAGES = join("node_modules", "@iconify-json");
|
|
17
18
|
// An icon's data only changes with the installed collection, which a
|
|
18
19
|
// deployment replaces; a week bounds how long an upgraded icon can lag.
|
|
19
20
|
const ICON_CACHE_CONTROL =
|
|
20
21
|
"public, max-age=604800, stale-while-revalidate=86400";
|
|
21
22
|
const ERROR_CACHE_CONTROL = "no-store";
|
|
22
23
|
const collections = new Map();
|
|
24
|
+
// By renderer module: a development server evaluates a new one after a layer
|
|
25
|
+
// changes, and each must find the collections loaded before it renders.
|
|
26
|
+
const rendererCollections = new WeakMap();
|
|
23
27
|
|
|
24
28
|
function collectionSpecifiers(prefix) {
|
|
25
29
|
return [
|
|
@@ -60,6 +64,44 @@ function collection(root, prefix) {
|
|
|
60
64
|
return collections.get(key);
|
|
61
65
|
}
|
|
62
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Every `@iconify-json/<prefix>` collection installed in the workspace under
|
|
69
|
+
* `root`, the ones the server render draws from. The workspace hoists every
|
|
70
|
+
* dependency, a module's collections included.
|
|
71
|
+
*/
|
|
72
|
+
export async function installedCollections(root) {
|
|
73
|
+
const prefixes = await readdir(join(root, COLLECTION_PACKAGES)).catch(
|
|
74
|
+
() => [],
|
|
75
|
+
);
|
|
76
|
+
const loaded = await Promise.all(
|
|
77
|
+
prefixes
|
|
78
|
+
.filter((prefix) => ICON_NAME.test(prefix))
|
|
79
|
+
.map((prefix) => collection(root, prefix)),
|
|
80
|
+
);
|
|
81
|
+
return loaded.filter(Boolean);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Hand a server renderer the installed collections, once per renderer module,
|
|
86
|
+
* before its first render. A collection that fails to load leaves the render
|
|
87
|
+
* with the bundled icons only.
|
|
88
|
+
*/
|
|
89
|
+
export function provideIconCollections(renderer, root = WORKSPACE_ROOT) {
|
|
90
|
+
if (!rendererCollections.has(renderer))
|
|
91
|
+
rendererCollections.set(
|
|
92
|
+
renderer,
|
|
93
|
+
installedCollections(root)
|
|
94
|
+
.then((loaded) => renderer.registerDmsIconCollections(loaded))
|
|
95
|
+
.catch((error) =>
|
|
96
|
+
console.error(
|
|
97
|
+
"DMS icon collections not loaded; server renders draw bundled icons only",
|
|
98
|
+
error,
|
|
99
|
+
),
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
return rendererCollections.get(renderer);
|
|
103
|
+
}
|
|
104
|
+
|
|
63
105
|
function jsonResponse(status, cacheControl, payload) {
|
|
64
106
|
return {
|
|
65
107
|
status,
|
package/templates/vue/server.mjs
CHANGED
|
@@ -5,7 +5,11 @@ import { Readable } from "node:stream";
|
|
|
5
5
|
import { pipeline } from "node:stream/promises";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { ofetch } from "ofetch";
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
relayedHeaders,
|
|
10
|
+
RequestBodyError,
|
|
11
|
+
UpstreamError,
|
|
12
|
+
} from "./server/auth/backend.mjs";
|
|
9
13
|
import { CROSS_ORIGIN_ERROR, isSameOrigin } from "./server/auth/client-ip.mjs";
|
|
10
14
|
import {
|
|
11
15
|
handleAuth,
|
|
@@ -36,7 +40,7 @@ import { documentStyleTags } from "./server/dev-styles.mjs";
|
|
|
36
40
|
import { handleEmailRender, watchEmailBundle } from "./server/email.mjs";
|
|
37
41
|
import { HOMEPAGE } from "./server/homepage.mjs";
|
|
38
42
|
import { htmlTag } from "./server/html-tag.mjs";
|
|
39
|
-
import
|
|
43
|
+
import * as icons from "./server/icons.mjs";
|
|
40
44
|
import * as requestFailure from "./server/request-failure.mjs";
|
|
41
45
|
import { handleTester } from "./server/tester.mjs";
|
|
42
46
|
import {
|
|
@@ -75,7 +79,7 @@ const HTML_RENDER_ROUTE = "/api/html/render";
|
|
|
75
79
|
const TESTER_ROUTE = /^\/api\/_dms\/tester\/?$/;
|
|
76
80
|
const LOCAL_ROUTES = [
|
|
77
81
|
[(pathname) => TESTER_ROUTE.test(pathname), handleTester],
|
|
78
|
-
[isIconRequest, handleIcons],
|
|
82
|
+
[icons.isIconRequest, icons.handleIcons],
|
|
79
83
|
];
|
|
80
84
|
const AUTH_PAGE = "/auth";
|
|
81
85
|
const ONBOARDING_PAGE = "/onboarding";
|
|
@@ -117,18 +121,13 @@ async function developmentServer() {
|
|
|
117
121
|
}
|
|
118
122
|
|
|
119
123
|
function backendHeaders(request) {
|
|
120
|
-
const
|
|
124
|
+
const headers = relayedHeaders(request, [
|
|
121
125
|
"accept",
|
|
122
126
|
"content-type",
|
|
123
127
|
"user-agent",
|
|
124
128
|
"x-content-language",
|
|
125
129
|
"x-realtime-session",
|
|
126
|
-
];
|
|
127
|
-
const headers = Object.fromEntries(
|
|
128
|
-
names.flatMap((name) =>
|
|
129
|
-
request.headers[name] ? [[name, request.headers[name]]] : [],
|
|
130
|
-
),
|
|
131
|
-
);
|
|
130
|
+
]);
|
|
132
131
|
const session = readSession(request);
|
|
133
132
|
if (session?.accessToken)
|
|
134
133
|
headers.authorization = `Bearer ${session.accessToken}`;
|
|
@@ -297,9 +296,11 @@ function serveAsset(pathname, request, response) {
|
|
|
297
296
|
}
|
|
298
297
|
|
|
299
298
|
async function ssrRenderer(devServer) {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
299
|
+
const renderer = devServer
|
|
300
|
+
? await devServer.ssrLoadModule("/ssr-renderer.ts")
|
|
301
|
+
: await (productionSsrRenderer ??= import(BUILT_SSR_RENDERER_PATH));
|
|
302
|
+
await icons.provideIconCollections(renderer);
|
|
303
|
+
return renderer;
|
|
303
304
|
}
|
|
304
305
|
|
|
305
306
|
export async function renderHtml(
|
|
@@ -24,6 +24,13 @@ import {
|
|
|
24
24
|
trackDmsServerScopes,
|
|
25
25
|
} from "./frontend-module";
|
|
26
26
|
import { frontendModules } from "./frontend-modules.generated";
|
|
27
|
+
import {
|
|
28
|
+
SSR_ICONS_ID,
|
|
29
|
+
serializeDmsIcons,
|
|
30
|
+
trackDmsIcons,
|
|
31
|
+
} from "./icon-hydration";
|
|
32
|
+
|
|
33
|
+
export { registerDmsIconCollections } from "./icon-hydration";
|
|
27
34
|
|
|
28
35
|
export interface DmsSsrPage {
|
|
29
36
|
component: string;
|
|
@@ -75,6 +82,14 @@ function asyncComponentsScript(names: Set<string>): string {
|
|
|
75
82
|
return `<script id="${SSR_ASYNC_COMPONENTS_ID}" type="application/json">${value}</script>`;
|
|
76
83
|
}
|
|
77
84
|
|
|
85
|
+
function iconsScript(names: Set<string>): string {
|
|
86
|
+
const value = JSON.stringify(serializeDmsIcons(names)).replaceAll(
|
|
87
|
+
"<",
|
|
88
|
+
"\\u003c",
|
|
89
|
+
);
|
|
90
|
+
return `<script id="${SSR_ICONS_ID}" type="application/json">${value}</script>`;
|
|
91
|
+
}
|
|
92
|
+
|
|
78
93
|
await setupFrontendModules(frontendModules);
|
|
79
94
|
|
|
80
95
|
export function isDmsFrontendPage(path: string): boolean {
|
|
@@ -117,6 +132,7 @@ async function renderDmsPageWithRuntime(
|
|
|
117
132
|
const head = createHead();
|
|
118
133
|
const ssrContext: DmsSsrContext = {};
|
|
119
134
|
let asyncComponents = new Set<string>();
|
|
135
|
+
let icons = new Set<string>();
|
|
120
136
|
const inertiaResult = await createInertiaApp({
|
|
121
137
|
page,
|
|
122
138
|
render: (app) => renderToString(app, ssrContext),
|
|
@@ -124,6 +140,7 @@ async function renderDmsPageWithRuntime(
|
|
|
124
140
|
async setup({ App, props, plugin }) {
|
|
125
141
|
const app = createSSRApp({ render: () => h(App, props) });
|
|
126
142
|
asyncComponents = trackDmsAsyncComponents(app);
|
|
143
|
+
icons = trackDmsIcons(app);
|
|
127
144
|
stopScopes = trackDmsServerScopes(app);
|
|
128
145
|
const configured = await configureDmsApp({
|
|
129
146
|
app,
|
|
@@ -173,7 +190,7 @@ async function renderDmsPageWithRuntime(
|
|
|
173
190
|
const renderedHead = await renderSSRHead(head);
|
|
174
191
|
return {
|
|
175
192
|
error: page.props.error,
|
|
176
|
-
body: `${inertiaResult.body}${asyncDataScript(runtime)}${asyncComponentsScript(asyncComponents)}`,
|
|
193
|
+
body: `${inertiaResult.body}${asyncDataScript(runtime)}${asyncComponentsScript(asyncComponents)}${iconsScript(icons)}`,
|
|
177
194
|
head: {
|
|
178
195
|
bodyAttrs: renderedHead.bodyAttrs,
|
|
179
196
|
headTags: `${inertiaResult.head.join("")}${renderedHead.headTags}`,
|
|
@@ -2,7 +2,15 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { basename, resolve } from "node:path";
|
|
3
3
|
import ui from "@nuxt/ui/vite";
|
|
4
4
|
import vue from "@vitejs/plugin-vue";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
createLogger,
|
|
7
|
+
defineConfig,
|
|
8
|
+
type LogLevel,
|
|
9
|
+
normalizePath,
|
|
10
|
+
type Plugin,
|
|
11
|
+
} from "vite";
|
|
12
|
+
import { autoImportDirectories } from "./frontend-build-loader";
|
|
13
|
+
import { frontendBuilds } from "./frontend-builds.generated";
|
|
6
14
|
import { orderHeadForFirstPaint } from "./head-order.mjs";
|
|
7
15
|
import { iconScanGlobs } from "./icon-scan.mjs";
|
|
8
16
|
|
|
@@ -40,21 +48,19 @@ const stableModuleAliases = Object.fromEntries(
|
|
|
40
48
|
}),
|
|
41
49
|
);
|
|
42
50
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
* `ajs dms build` runs Vite at "warn" and reports the steps itself; a verbose
|
|
52
|
+
* run, `dev` and a hand-run build keep Vite's own output.
|
|
53
|
+
*/
|
|
54
|
+
const logLevel = (process.env.DMS_VITE_LOG_LEVEL ?? "info") as LogLevel;
|
|
55
|
+
const logger = createLogger(logLevel);
|
|
56
|
+
/**
|
|
57
|
+
* Only what each module declares in its `dms.frontend.build.ts`: a module
|
|
58
|
+
* without one has nothing auto-imported.
|
|
49
59
|
*/
|
|
50
|
-
const importDirectories =
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
"app/utils/**/*",
|
|
55
|
-
"app/build/composables/**/*",
|
|
56
|
-
"app/build/types/**/*",
|
|
57
|
-
].map((glob) => ({ glob: normalizePath(resolve(root, glob)), types: true })),
|
|
60
|
+
const importDirectories = autoImportDirectories(
|
|
61
|
+
registry.modules,
|
|
62
|
+
frontendBuilds,
|
|
63
|
+
(message) => logger.warn(message),
|
|
58
64
|
);
|
|
59
65
|
const optimizedDependencies = [
|
|
60
66
|
"vue",
|
|
@@ -90,8 +96,9 @@ const optimizedDependencies = [
|
|
|
90
96
|
* startup, pays that cost a single time and in a place where it reads as
|
|
91
97
|
* startup rather than as a crash.
|
|
92
98
|
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
99
|
+
* Globs are POSIX, always: `resolve` returns a native path, whose Windows
|
|
100
|
+
* backslashes a glob matcher reads as escape characters, not as separators.
|
|
101
|
+
* `normalizePath` is Vite's own answer to this, the identity on POSIX.
|
|
95
102
|
*/
|
|
96
103
|
const optimizerEntries = [
|
|
97
104
|
resolve(__dirname, "main.ts"),
|
|
@@ -117,12 +124,6 @@ const uiInertiaLinkImport = resolve(
|
|
|
117
124
|
"node_modules/@nuxt/ui/dist/runtime/vue/overrides/inertia/Link.vue",
|
|
118
125
|
);
|
|
119
126
|
|
|
120
|
-
/**
|
|
121
|
-
* `ajs dms build` runs Vite at "warn" and reports the steps itself; a verbose
|
|
122
|
-
* run, `dev` and a hand-run build keep Vite's own output.
|
|
123
|
-
*/
|
|
124
|
-
const logLevel = (process.env.DMS_VITE_LOG_LEVEL ?? "info") as LogLevel;
|
|
125
|
-
|
|
126
127
|
export default defineConfig({
|
|
127
128
|
logLevel,
|
|
128
129
|
plugins: [
|
|
@@ -175,6 +176,7 @@ export default defineConfig({
|
|
|
175
176
|
"useI18n",
|
|
176
177
|
"useError",
|
|
177
178
|
"useDmsLazyAsyncData",
|
|
179
|
+
"useDmsPageLoading",
|
|
178
180
|
"useDmsApp",
|
|
179
181
|
"useDmsRoute",
|
|
180
182
|
"useDmsRouter",
|