@antelopejs/dms-frontend 0.3.12 → 0.4.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 CHANGED
@@ -188,6 +188,61 @@ 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
+ ### Private components
192
+
193
+ A component registers as public by default: it joins the application's global components and resolves by name anywhere. A component that only exists to render the module's own backend pages registers as private instead:
194
+
195
+ ```ts
196
+ sdk.registerComponent("BillingTotals", BillingTotals, { private: true });
197
+ ```
198
+
199
+ A private component is not registered with `app.component`, so no template or other module reaches it by name. `resolveDmsComponent(name, owner)` returns it only when `owner` is the registering module, and then ahead of a public component of the same name; without an owner, only public components resolve. The generic page renderer passes the page's owner, so a backend page tree naming the component renders it, and the page preloader and the server render's hydration handover cover it too.
200
+
201
+ The owner is the page payload's `module` field: the name of the frontend module that owns the page's component tree, as the backend added it (the module's `name` in the frontend manifest, which the generated loader hands each module's setup). A backend that does not send `module` reaches no private component, whatever the tree names.
202
+
203
+ ### Auto-imports (`dms.frontend.build.ts`)
204
+
205
+ 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`:
206
+
207
+ ```ts
208
+ import { defineDmsFrontendBuild } from "#dms/frontend-build";
209
+
210
+ export default defineDmsFrontendBuild((build) => {
211
+ build.registerAutoImports([
212
+ "layers/*/app/composables",
213
+ "layers/*/app/utils",
214
+ "layers/*/app/types",
215
+ ]);
216
+ });
217
+ ```
218
+
219
+ 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.
220
+
221
+ 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.
222
+
223
+ 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.
224
+
225
+ ### Page loading state
226
+
227
+ 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:
228
+
229
+ ```vue
230
+ <script setup lang="ts">
231
+ const pageLoading = useDmsPageLoading();
232
+ </script>
233
+
234
+ <template>
235
+ <slot />
236
+ <PageSkeleton v-if="pageLoading" />
237
+ </template>
238
+ ```
239
+
240
+ 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.
241
+
242
+ ### Interface language
243
+
244
+ 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.
245
+
191
246
  ### Declaring the loader releases a module supports
192
247
 
193
248
  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 +467,8 @@ The other files the workspace derives from every module follow their sources
412
467
  the same way. A file added to, changed in or removed from a module's `public/`
413
468
  is served, or no longer served, at once. Adding or removing an
414
469
  `app/config/shortcuts-registry.ts` or a `dms.frontend.ts` regenerates the
415
- aggregated shortcuts or the module loader, and the page reloads. A layer
470
+ aggregated shortcuts or the module loader, and the page reloads. Adding,
471
+ changing or removing a `dms.frontend.build.ts` restarts Vite. A layer
416
472
  directory added to or removed from `layers/` is the exception: Vite reads the
417
473
  `#<layer>` aliases and the auto-imported directories at startup only, so the
418
474
  dev server says it needs a restart.
@@ -436,6 +492,14 @@ needs no exception for the Iconify API. Icons reach the page two ways:
436
492
  answers `404`, and a name outside Iconify's naming rule `400`. `ajs dms dev`
437
493
  and `ajs dms start` run the same server, so both serve the route.
438
494
 
495
+ The server render draws every icon, bundled or not: the frontend server loads
496
+ each installed `@iconify-json/*` collection once and hands it to the renderer,
497
+ and an icon is drawn in its first render as soon as its data is loaded, instead
498
+ of after the component mounts. The data of each icon a render drew travels in
499
+ the page, so the browser draws the same icons as it hydrates; an icon no
500
+ installed collection holds stays empty on the server and is fetched in the
501
+ browser as before.
502
+
439
503
  ## Options
440
504
 
441
505
  | Option | Environment | Purpose |
@@ -449,6 +513,7 @@ needs no exception for the Iconify API. Icons reach the page two ways:
449
513
  | | `HOST` | Address the frontend server binds to (default `0.0.0.0`) |
450
514
  | | `DMS_COOKIE_SECURE` | Secure cookies (`true` by default; `ajs dms dev` defaults to `false`) |
451
515
  | | `DMS_TRUSTED_PROXY_HOPS` | Number of trusted, rightmost reverse-proxy hops (default `0`) |
516
+ | | `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
517
  | | `DMS_SESSION_SECRET` | Session cookie encryption key, 32 characters or more (required for login) |
453
518
  | | `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
519
  | | `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 {
@@ -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
- return writeFrontendModuleLoader(workspaceDir, registry) || wroteRegistry;
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) === config_1.FRONTEND_MODULE_ENTRY,
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
@@ -119,6 +119,7 @@ function readLayerPackageName(layerPath) {
119
119
  function resolveLayer(layerPath, mod) {
120
120
  return {
121
121
  path: layerPath,
122
+ name: mod.name,
122
123
  sourcePath: mod.path,
123
124
  packageName: readLayerPackageName(layerPath),
124
125
  priority: mod.priority,
@@ -27,6 +27,7 @@ export interface FrontendManifest {
27
27
  }
28
28
  export interface ResolvedLayer {
29
29
  path: string;
30
+ name?: string;
30
31
  sourcePath?: string;
31
32
  packageName?: string;
32
33
  priority?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/dms-frontend",
3
- "version": "0.3.12",
3
+ "version": "0.4.0",
4
4
  "description": "Frontend-agnostic loader for AntelopeJS DMS, shipping the Vue 3 renderer (Vite, Inertia, SSR)",
5
5
  "keywords": [
6
6
  "antelope",
@@ -37,6 +37,7 @@ interface PageRoute {
37
37
  interface PagePayload {
38
38
  layout?: PageLayout;
39
39
  route?: PageRoute;
40
+ module?: string;
40
41
  }
41
42
 
42
43
  interface DynamicPageProps {
@@ -55,7 +56,8 @@ function resolveDefinition(
55
56
  return {
56
57
  id,
57
58
  component: definition.componentName
58
- ? resolveDmsComponent(definition.componentName) || "div"
59
+ ? resolveDmsComponent(definition.componentName, props.page.module) ||
60
+ "div"
59
61
  : "div",
60
62
  componentName: definition.componentName,
61
63
  options: definition.options,
@@ -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
- function pageLocale(props: DmsPageProps): string {
98
- const language = props.user?.language;
99
- if (typeof language !== "string") return DEFAULT_LOCALE;
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 : DEFAULT_LOCALE;
111
+ return supportedLocales.includes(locale) ? locale : undefined;
102
112
  }
103
113
 
104
- function readDmsAsyncData(): Record<string, unknown> {
105
- if (typeof document === "undefined") return {};
106
- const element = document.getElementById(SSR_ASYNC_DATA_ID);
107
- if (!element?.textContent) return {};
108
- const data = JSON.parse(element.textContent) as Record<string, unknown>;
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 data;
120
+ return value;
111
121
  }
112
122
 
113
- function readDmsAsyncComponents(): string[] {
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
- async () => {
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
- hydrateDmsPageProps(props.value);
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, null, { default: () => pageContent });
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
- readDmsAsyncData(),
261
+ readServerRendered<Record<string, unknown>>(SSR_ASYNC_DATA_ID, {}),
251
262
  );
252
- const locale = pageLocale(options.initialPageProps);
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(readDmsAsyncComponents()),
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
+ }