@antelopejs/dms-frontend 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +4 -4
  2. package/dist/commands/build-action.d.ts +8 -5
  3. package/dist/commands/build-action.js +16 -41
  4. package/dist/commands/build.js +9 -15
  5. package/dist/commands/dev-action.d.ts +2 -0
  6. package/dist/commands/dev-action.js +14 -3
  7. package/dist/commands/dev.js +3 -1
  8. package/dist/commands/start-action.d.ts +2 -0
  9. package/dist/commands/start-action.js +65 -47
  10. package/dist/commands/start.js +8 -6
  11. package/dist/common.d.ts +1 -0
  12. package/dist/common.js +1 -0
  13. package/dist/config.d.ts +2 -0
  14. package/dist/config.js +9 -0
  15. package/dist/derived-outputs.d.ts +1 -5
  16. package/dist/derived-outputs.js +48 -35
  17. package/dist/dev-modules.d.ts +5 -0
  18. package/dist/dev-modules.js +88 -0
  19. package/dist/entry-weight.d.ts +24 -0
  20. package/dist/entry-weight.js +86 -0
  21. package/dist/fs-sync.d.ts +1 -0
  22. package/dist/fs-sync.js +6 -0
  23. package/dist/help.js +12 -4
  24. package/dist/index.js +4 -6
  25. package/dist/layer-watch.js +7 -2
  26. package/dist/layers.d.ts +2 -3
  27. package/dist/layers.js +1 -95
  28. package/dist/locale-files.d.ts +6 -0
  29. package/dist/locale-files.js +75 -0
  30. package/dist/manifest.d.ts +8 -8
  31. package/dist/manifest.js +17 -39
  32. package/dist/materialize.js +6 -0
  33. package/dist/module-set.d.ts +14 -12
  34. package/dist/module-set.js +39 -31
  35. package/dist/private-values.d.ts +3 -2
  36. package/dist/private-values.js +16 -5
  37. package/dist/project-modules-child.d.ts +13 -0
  38. package/dist/project-modules-child.js +26 -0
  39. package/dist/project-modules.d.ts +47 -0
  40. package/dist/project-modules.js +268 -0
  41. package/dist/workspace-setup.d.ts +3 -7
  42. package/dist/workspace-setup.js +79 -62
  43. package/dist/workspace.d.ts +8 -4
  44. package/dist/workspace.js +1 -1
  45. package/package.json +13 -8
  46. package/skills/dms-frontend/SKILL.md +2 -2
  47. package/templates/vue/app-runtime.ts +8 -1
  48. package/templates/vue/client-chunking.mjs +61 -0
  49. package/templates/vue/email-renderer.ts +6 -1
  50. package/templates/vue/entry-weight.mjs +132 -0
  51. package/templates/vue/frontend-build-loader.ts +1 -1
  52. package/templates/vue/frontend-module.ts +168 -1
  53. package/templates/vue/gitignore +1 -0
  54. package/templates/vue/main.ts +32 -2
  55. package/templates/vue/network-error.ts +3 -1
  56. package/templates/vue/package.json +3 -3
  57. package/templates/vue/patches/@vueuse__core@14.4.0.patch +24 -0
  58. package/templates/vue/pnpm-workspace.yaml +5 -0
  59. package/templates/vue/prepare-types.mjs +2 -7
  60. package/templates/vue/public-options.ts +19 -0
  61. package/templates/vue/server/auth/backend.mjs +20 -3
  62. package/templates/vue/server/auth/client-ip.mjs +64 -22
  63. package/templates/vue/server/auth/session.mjs +4 -11
  64. package/templates/vue/server/backend-proxies.mjs +13 -2
  65. package/templates/vue/server/backend-response.mjs +2 -13
  66. package/templates/vue/server/cli-report.mjs +42 -18
  67. package/templates/vue/server/client-manifest.mjs +34 -58
  68. package/templates/vue/server/dev-private-files.mjs +1 -1
  69. package/templates/vue/server/dev-styles.mjs +16 -5
  70. package/templates/vue/server/homepage.mjs +4 -1
  71. package/templates/vue/server/in-place-render.mjs +110 -0
  72. package/templates/vue/server/module-event.mjs +23 -8
  73. package/templates/vue/server/module-server.mjs +4 -4
  74. package/templates/vue/server/module-testing.mjs +5 -3
  75. package/templates/vue/server/page-props.mjs +10 -0
  76. package/templates/vue/server/proxy-compression.mjs +15 -8
  77. package/templates/vue/server/proxy-headers.mjs +12 -1
  78. package/templates/vue/server/proxy-redirects.mjs +66 -0
  79. package/templates/vue/server/proxy-transfers.mjs +110 -0
  80. package/templates/vue/server/public-endpoints.mjs +2 -2
  81. package/templates/vue/server/public-options.mjs +49 -0
  82. package/templates/vue/server/request-routing.mjs +20 -0
  83. package/templates/vue/server/web-body.mjs +24 -0
  84. package/templates/vue/server-module.ts +19 -7
  85. package/templates/vue/server.mjs +98 -78
  86. package/templates/vue/sfc-global-types.mjs +119 -0
  87. package/templates/vue/ssr-renderer.ts +27 -1
  88. package/templates/vue/ui-kbd.mjs +45 -0
  89. package/templates/vue/vite.config.ts +104 -47
  90. package/templates/vue/vite.email.config.ts +6 -3
  91. package/templates/vue/vite.server.config.ts +5 -2
  92. package/dist/commands/snapshot-action.d.ts +0 -6
  93. package/dist/commands/snapshot-action.js +0 -53
  94. package/dist/commands/snapshot.d.ts +0 -2
  95. package/dist/commands/snapshot.js +0 -55
  96. package/dist/snapshot.d.ts +0 -25
  97. package/dist/snapshot.js +0 -90
  98. package/templates/vue/server/backend-wake.mjs +0 -129
@@ -0,0 +1,61 @@
1
+ // How the client bundle is cut into files, and what the build says about it.
2
+ //
3
+ // Kept beside vite.config.ts rather than inside it so the options can be
4
+ // exercised on their own, the way head-order.mjs and client-server-guard.mjs are.
5
+
6
+ /**
7
+ * Rolldown's `output.codeSplitting` for the client build.
8
+ *
9
+ * By default Rolldown gives every set of dynamic imports that shares a module
10
+ * its own common chunk. The DMS's pages are hundreds of dynamic imports (a
11
+ * module's components and pages are `import.meta.glob`ed), so the code the
12
+ * entry also needs lands in about sixty small common chunks the entry imports
13
+ * before anything runs: one request each, 55 to 60 more files on every page
14
+ * than the single entry file Rollup made, for no byte saved.
15
+ *
16
+ * The `$initial` tag is every module the entry imports statically, directly or
17
+ * not. One group over it puts them in the entry file again, and leaves every
18
+ * lazy chunk to Rolldown's own splitting, which a page only loads when it needs
19
+ * it: what an anonymous page loads is still what that page's own routes pull.
20
+ *
21
+ * Deliberately not here: groups over what the lazy chunks share (the UI
22
+ * libraries, a chunk per module). They cut a signed-in page's files by a
23
+ * quarter to four fifths, but every group is loaded whole by each page that
24
+ * uses any of it, and the anonymous pages pay 70 to 115 KB of Brotli for code
25
+ * only the signed-in shell uses.
26
+ */
27
+ export const clientCodeSplitting = {
28
+ groups: [{ name: "entry", tags: ["$initial"] }],
29
+ };
30
+
31
+ const MODULE_ENTRY = /[\\/]dms\.frontend\.[cm]?[jt]sx?$/;
32
+
33
+ /**
34
+ * Rolldown's `onLog` for every build: drops the
35
+ * INEFFECTIVE_DYNAMIC_IMPORT notices whose dynamic importer is a module's
36
+ * `dms.frontend.ts`.
37
+ *
38
+ * That file `import.meta.glob`s every component of the module so a page can
39
+ * resolve one by name. A few of them (StatusPill, MonoCell, IdentityCell: the
40
+ * table cell renderers) are also imported statically by the entry, which is
41
+ * what a cell renderer needs at hydration. The glob entry then resolves to the
42
+ * chunk already loaded: nothing is duplicated and nothing was meant to be
43
+ * lazy, and nobody can act on the notice. A `import()` a module author
44
+ * writes by hand for a file that is also imported statically is the same
45
+ * mistake with a different cause (the lazy load never happens), and still
46
+ * warns.
47
+ *
48
+ * @param {string} level
49
+ * @param {{ code?: string, ids?: string[] }} log
50
+ * @param {(level: string, log: object) => void} defaultHandler
51
+ */
52
+ export function onBuildLog(level, log, defaultHandler) {
53
+ if (
54
+ log.code === "INEFFECTIVE_DYNAMIC_IMPORT" &&
55
+ log.ids?.length &&
56
+ MODULE_ENTRY.test(log.ids[0])
57
+ ) {
58
+ return;
59
+ }
60
+ defaultHandler(level, log);
61
+ }
@@ -6,6 +6,7 @@ import { appConfigs } from "./app-configs.generated";
6
6
  import moduleRegistry from "./generated-frontend-modules.json";
7
7
  import { loadEmailLocaleMessages } from "./email-locales";
8
8
  import { stripSsrMarkers } from "./email-markup";
9
+ import { parsePublicOptions } from "./public-options";
9
10
 
10
11
  interface EmailModule {
11
12
  default: Component;
@@ -20,6 +21,8 @@ interface EmailRenderOptions {
20
21
  locale?: string;
21
22
  }
22
23
 
24
+ const runtimeOptions = parsePublicOptions(process.env.DMS_PUBLIC_OPTIONS);
25
+
23
26
  const registries = import.meta.glob<EmailRegistry>(
24
27
  "/frontend-modules/*/dms.email.ts",
25
28
  { eager: true },
@@ -157,7 +160,9 @@ export async function renderEmail(
157
160
  app.provide("dmsEmailAppConfig", defu({}, ...emailAppConfigs));
158
161
  const publicConfig = defu(
159
162
  {},
160
- ...moduleRegistry.modules.map((module) => module.options),
163
+ ...moduleRegistry.modules.map(
164
+ (module) => runtimeOptions?.[module.id] ?? module.options,
165
+ ),
161
166
  );
162
167
  app.provide("dmsEmailRuntimeConfig", {
163
168
  public: defu(
@@ -0,0 +1,132 @@
1
+ // What each frontend module adds to the client entry.
2
+ //
3
+ // Every page loads the entry, and everything it imports statically, before it
4
+ // runs anything: a module's `dms.frontend.ts`, its plugins and whatever they
5
+ // import land there, on every page of every application that loads the
6
+ // module. The client build records, from the bundle's own metadata, how much
7
+ // of the entry each module's sources make up; `ajs dms build` reports it and
8
+ // warns about a module above its threshold.
9
+ //
10
+ // Kept beside vite.config.ts rather than inside it so the plugin can be
11
+ // exercised on its own, the way client-chunking.mjs is.
12
+
13
+ import { mkdirSync, writeFileSync } from "node:fs";
14
+ import { dirname, join, relative, sep } from "node:path";
15
+
16
+ /** Where the client build writes the attribution, under `dist/client`. */
17
+ export const ENTRY_WEIGHT_FILE = ".vite/entry-weight.json";
18
+
19
+ /** Owners of the entry's bytes that are no frontend module. */
20
+ export const SHARED_OWNER = "shared";
21
+ export const TRANSLATIONS_OWNER = "translations";
22
+
23
+ const LOCALE_CATALOG = /[\\/]locales\.generated[\\/]/;
24
+
25
+ function stripQuery(id) {
26
+ const query = id.indexOf("?");
27
+ return query === -1 ? id : id.slice(0, query);
28
+ }
29
+
30
+ function isInside(root, file) {
31
+ const path = relative(root, file);
32
+ return Boolean(path) && !path.startsWith("..") && !path.startsWith(sep);
33
+ }
34
+
35
+ /**
36
+ * The owner of a module id of the bundle: the frontend module whose sources
37
+ * hold the file, by its manifest name; the merged translation catalogs; else
38
+ * what every page shares (the renderer's runtime, the UI and icon libraries,
39
+ * an installed package).
40
+ *
41
+ * @param {{ root: string, name?: string, packageName?: string, id: string }[]} modules
42
+ */
43
+ export function entryOwner(id, modules) {
44
+ if (id.startsWith("\0")) return SHARED_OWNER;
45
+ const file = stripQuery(id);
46
+ if (/[\\/]node_modules[\\/]/.test(file)) return SHARED_OWNER;
47
+ if (LOCALE_CATALOG.test(file)) return TRANSLATIONS_OWNER;
48
+ const module = modules.find(({ root }) => isInside(root, file));
49
+ return module
50
+ ? (module.name ?? module.packageName ?? module.id)
51
+ : SHARED_OWNER;
52
+ }
53
+
54
+ /**
55
+ * The files a page loads before its first line runs: the entry chunks and
56
+ * their static imports, transitively, by file name.
57
+ */
58
+ function entryClosure(bundle) {
59
+ const chunks = Object.values(bundle).filter(
60
+ (output) => output.type === "chunk",
61
+ );
62
+ const byName = new Map(chunks.map((chunk) => [chunk.fileName, chunk]));
63
+ const seen = new Set();
64
+ const visit = (chunk) => {
65
+ if (!chunk || seen.has(chunk.fileName)) return;
66
+ seen.add(chunk.fileName);
67
+ for (const file of chunk.imports ?? []) visit(byName.get(file));
68
+ };
69
+ for (const chunk of chunks) if (chunk.isEntry) visit(chunk);
70
+ return [...seen].map((file) => byName.get(file));
71
+ }
72
+
73
+ /**
74
+ * The bytes of the entry each owner accounts for. Each chunk's bytes are
75
+ * shared out among the modules it holds in proportion to the code Rolldown
76
+ * rendered for each (`renderedLength`, before minification): an estimate that
77
+ * needs nothing the bundle does not already hold.
78
+ *
79
+ * @returns {{ files: string[], bytes: number, owners: Record<string, number> }}
80
+ */
81
+ export function attributeEntry(bundle, modules) {
82
+ const files = entryClosure(bundle);
83
+ const owners = {};
84
+ let bytes = 0;
85
+ for (const chunk of files) {
86
+ const size = Buffer.byteLength(chunk.code);
87
+ bytes += size;
88
+ const rendered = Object.entries(chunk.modules ?? {});
89
+ const total = rendered.reduce(
90
+ (sum, [, info]) => sum + info.renderedLength,
91
+ 0,
92
+ );
93
+ if (!total) {
94
+ owners[SHARED_OWNER] = (owners[SHARED_OWNER] ?? 0) + size;
95
+ continue;
96
+ }
97
+ for (const [id, info] of rendered) {
98
+ const owner = entryOwner(id, modules);
99
+ owners[owner] =
100
+ (owners[owner] ?? 0) + (size * info.renderedLength) / total;
101
+ }
102
+ }
103
+ for (const owner of Object.keys(owners))
104
+ owners[owner] = Math.round(owners[owner]);
105
+ return { files: files.map((chunk) => chunk.fileName), bytes, owners };
106
+ }
107
+
108
+ /**
109
+ * Records, in the client build, what each frontend module adds to the entry
110
+ * (`ENTRY_WEIGHT_FILE`). Server builds are left alone: their bundle is no
111
+ * page's entry.
112
+ */
113
+ export function recordEntryWeight(modules) {
114
+ let ssrBuild = false;
115
+ return {
116
+ name: "dms-entry-weight",
117
+ configResolved(config) {
118
+ ssrBuild = Boolean(config.build.ssr);
119
+ },
120
+ // Once the bundle is written: plugins that generate the bundle after this
121
+ // one still rewrite the chunks' code.
122
+ writeBundle(options, bundle) {
123
+ if (ssrBuild || !options.dir) return;
124
+ const file = join(options.dir, ENTRY_WEIGHT_FILE);
125
+ mkdirSync(dirname(file), { recursive: true });
126
+ writeFileSync(
127
+ file,
128
+ `${JSON.stringify(attributeEntry(bundle, modules), null, 2)}\n`,
129
+ );
130
+ },
131
+ };
132
+ }
@@ -1,6 +1,6 @@
1
1
  import { isAbsolute, posix, resolve } from "node:path";
2
2
  import { normalizePath } from "vite";
3
- import type { DmsFrontendBuildSetup } from "./frontend-build";
3
+ import type { DmsFrontendBuildSetup } from "./frontend-build.ts";
4
4
 
5
5
  /** A module's `dms.frontend.build.ts`, as the generated loader imports it. */
6
6
  export interface FrontendBuildDeclaration {
@@ -12,7 +12,9 @@ import { defu } from "defu";
12
12
  import { type FetchOptions, ofetch } from "ofetch";
13
13
  import {
14
14
  type App,
15
+ type AppConfig,
15
16
  type Component,
17
+ type ComponentInternalInstance,
16
18
  type ComponentPublicInstance,
17
19
  type ComputedRef,
18
20
  camelize,
@@ -55,6 +57,7 @@ import {
55
57
  mergeLayerAppConfigs,
56
58
  withAppConfigChanges,
57
59
  } from "./layer-app-config";
60
+ import type { DmsPublicOptionsById } from "./public-options";
58
61
 
59
62
  export interface DmsPageRoute {
60
63
  displayName?: string;
@@ -457,11 +460,34 @@ export interface DmsFrontendModule {
457
460
  setup(sdk: DmsFrontendSdk): void | Promise<void>;
458
461
  }
459
462
  export interface DmsFrontendModuleRegistration {
463
+ /** The module's id in the generated module registry. */
464
+ id?: string;
460
465
  /** The module's manifest name, which collision warnings name it by. */
461
466
  name?: string;
462
467
  module: DmsFrontendModule;
463
468
  options: DmsModuleOptions;
464
469
  }
470
+
471
+ /**
472
+ * The registrations with the public options the server runs with in place of
473
+ * the build's: `ajs dms start` computes them from the project's configuration
474
+ * and the deployment's variables. Unset, the build's options stand.
475
+ */
476
+ export function withPublicOptions(
477
+ registrations: DmsFrontendModuleRegistration[],
478
+ byId: DmsPublicOptionsById | undefined,
479
+ ): DmsFrontendModuleRegistration[] {
480
+ if (!byId) return registrations;
481
+ return registrations.map((registration) => {
482
+ const options = registration.id ? byId[registration.id] : undefined;
483
+ return options
484
+ ? {
485
+ ...registration,
486
+ options: { ...registration.options, public: options },
487
+ }
488
+ : registration;
489
+ });
490
+ }
465
491
  /** A layer's `app/app.config.ts`, as `app-configs.generated.ts` lists them. */
466
492
  export interface DmsLayerAppConfig {
467
493
  /** The id of the frontend module the layer belongs to. */
@@ -1829,6 +1855,9 @@ function startAsyncData<T>(
1829
1855
  const idle = entry.result.status.value === "idle";
1830
1856
  if (runtime.isServer) {
1831
1857
  if (!immediate || !onServer) return undefined;
1858
+ // A render that already answers a redirect throws its HTML away: what it
1859
+ // still has to read would reach nobody.
1860
+ if (runtime.serverRedirect) return undefined;
1832
1861
  if (idle && options.lazy) entry.schedule();
1833
1862
  else if (idle) void entry.load();
1834
1863
  const settled = entry.settled();
@@ -2464,6 +2493,35 @@ export function defineDmsPageMeta(meta: Record<string, unknown>): void {
2464
2493
  onServerPrefetch(() => navigation.then(ignoreSettlement, ignoreSettlement));
2465
2494
  }
2466
2495
  function ignoreSettlement(): void {}
2496
+ /**
2497
+ * What Vue's development build passes `errorHandler` for an error thrown by a
2498
+ * component's `setup`, sync or async (production passes a link instead; it has
2499
+ * no warnings to scope either).
2500
+ */
2501
+ const SETUP_FUNCTION_ERROR = "setup function";
2502
+ /**
2503
+ * Whether a Vue warning comes from a component whose `setup` failed, or from
2504
+ * under it. Vue still renders such a component, against the setup state that
2505
+ * never came: in development, where a single-file component's template is
2506
+ * compiled apart from its setup, the template reads the missing bindings, and
2507
+ * every component it uses becomes `undefined` ("Invalid vnode type when
2508
+ * creating vnode: undefined", then "Component <Anonymous> is missing template
2509
+ * or render function" for the comment Vue renders in its place). The error
2510
+ * page replaces that output (a server renders the page again, the browser
2511
+ * swaps it), and the failure itself is already reported.
2512
+ *
2513
+ * Walks internal instances: a `<script setup>` component exposes itself, so
2514
+ * its children's `$parent` is an expose proxy, not the proxy `errorHandler`
2515
+ * received.
2516
+ */
2517
+ function withinFailedSetup(
2518
+ instance: ComponentInternalInstance | null,
2519
+ failedSetups: WeakSet<ComponentInternalInstance>,
2520
+ ): boolean {
2521
+ for (let current = instance; current; current = current.parent)
2522
+ if (failedSetups.has(current)) return true;
2523
+ return false;
2524
+ }
2467
2525
  /** Shows the error page in place of the page, keeping the first failure. */
2468
2526
  function failPage(runtime: DmsFrontendRuntime, reason: unknown): void {
2469
2527
  runtime.currentError.value ??=
@@ -2554,6 +2612,80 @@ export const DmsLink: Component = defineComponent({
2554
2612
  },
2555
2613
  });
2556
2614
 
2615
+ /** How a keyboard hint prints its modifiers: macOS symbols, or PC key names. */
2616
+ export type DmsKbdPlatform = "mac" | "other";
2617
+ /**
2618
+ * Where Nuxt UI's key hints read the platform: called in the setup of each
2619
+ * component that prints one, it returns the platform that component renders.
2620
+ * It must read on the server the platform it renders there, and keep it while
2621
+ * its caller hydrates, so the browser's first render matches the server's.
2622
+ */
2623
+ export type DmsKbdPlatformSource = () => Readonly<Ref<DmsKbdPlatform>>;
2624
+
2625
+ const KBD_PLATFORM_KEY: InjectionKey<DmsKbdPlatformSource> =
2626
+ Symbol("dms:kbd-platform");
2627
+ const MODIFIER_KBD_KEYS: Record<
2628
+ DmsKbdPlatform,
2629
+ Record<"meta" | "ctrl" | "alt", string>
2630
+ > = {
2631
+ mac: { meta: "⌘", ctrl: "⌃", alt: "⌥" },
2632
+ other: { meta: "Ctrl", ctrl: "Ctrl", alt: "Alt" },
2633
+ };
2634
+ const isModifierKbdKey = (value: string): value is "meta" | "ctrl" | "alt" =>
2635
+ value === "meta" || value === "ctrl" || value === "alt";
2636
+
2637
+ /**
2638
+ * Gives Nuxt UI's key hints (`UKbd`, the `kbds` of its menus, the dashboard
2639
+ * search button) the application's keyboard platform, so the server prints
2640
+ * `meta` as "⌘" or "Ctrl" from the first render instead of a blank the
2641
+ * browser fills in after mounting.
2642
+ */
2643
+ export function provideDmsKbdPlatform(
2644
+ app: App,
2645
+ source: DmsKbdPlatformSource,
2646
+ ): void {
2647
+ app.provide(KBD_PLATFORM_KEY, source);
2648
+ }
2649
+
2650
+ interface NuxtUiKbd {
2651
+ macOS: ComputedRef<unknown>;
2652
+ getKbdKey: (value?: string) => string | undefined;
2653
+ }
2654
+
2655
+ /**
2656
+ * Nuxt UI's `useKbd` as the DMS runs it (`ui-kbd.mjs` routes it here): a hint
2657
+ * renders the same on the server and in the hydrating browser. Nuxt UI prints
2658
+ * `meta`, `ctrl` and `alt` from a map shared by every hint and filled by the
2659
+ * first one's `onMounted`, so a hint hydrating after that (in an async
2660
+ * component) printed "⌘" over the server's blank. Each hint now keeps the
2661
+ * server's value until its own `onMounted`; with a platform provided
2662
+ * (`provideDmsKbdPlatform`), that value is the platform's from the start.
2663
+ *
2664
+ * @internal
2665
+ */
2666
+ export function useDmsKbd(
2667
+ shared: () => NuxtUiKbd,
2668
+ keys: Readonly<Record<string, string>>,
2669
+ ): NuxtUiKbd {
2670
+ const { macOS } = shared();
2671
+ const instance = getCurrentInstance();
2672
+ const source = instance ? inject(KBD_PLATFORM_KEY, null) : null;
2673
+ const platform = source ? source() : null;
2674
+ // A hydrating component's vnode is bound to its server node before setup.
2675
+ const hydrating = ref(!!instance?.vnode.el);
2676
+ if (hydrating.value) onMounted(() => (hydrating.value = false));
2677
+ return {
2678
+ macOS,
2679
+ getKbdKey(value) {
2680
+ if (!value) return undefined;
2681
+ if (!isModifierKbdKey(value)) return keys[value] || value;
2682
+ if (platform) return MODIFIER_KBD_KEYS[platform.value][value];
2683
+ if (typeof window === "undefined" || hydrating.value) return " ";
2684
+ return MODIFIER_KBD_KEYS[macOS.value ? "mac" : "other"][value];
2685
+ },
2686
+ };
2687
+ }
2688
+
2557
2689
  export const DmsClientOnly = defineComponent({
2558
2690
  name: "DmsClientOnly",
2559
2691
  setup(_, { slots }) {
@@ -2793,6 +2925,17 @@ export function useDmsPageLoading(): Readonly<Ref<boolean>> {
2793
2925
  return readonly(useDmsRuntime().pageLoading);
2794
2926
  }
2795
2927
 
2928
+ /**
2929
+ * Whether the browser is hydrating the page the server rendered, whose first
2930
+ * render must match the server's HTML: from the start of the application to
2931
+ * the tick the page's `<Suspense>` resolves. False on the server, in a page
2932
+ * the server did not render, and after a client navigation. As Nuxt's
2933
+ * `nuxtApp.isHydrating`; read once, it is not reactive.
2934
+ */
2935
+ export function isDmsHydrating(): boolean {
2936
+ return useDmsRuntime().hydrating;
2937
+ }
2938
+
2796
2939
  /**
2797
2940
  * The props of the `<Suspense>` around the page that keep `useDmsPageLoading`
2798
2941
  * in step with it. Cleared before the boundary unmounts, so a page left while
@@ -3344,7 +3487,31 @@ export async function installDmsPlugins(
3344
3487
  if (browserRuntime === runtime) browserRuntime = undefined;
3345
3488
  });
3346
3489
  }
3347
- app.config.errorHandler = (reason) => failPage(runtime, reason);
3490
+ const failedSetups = new WeakSet<ComponentInternalInstance>();
3491
+ const warnOutsideFailedSetups: AppConfig["warnHandler"] = (
3492
+ message,
3493
+ instance,
3494
+ trace,
3495
+ ) => {
3496
+ // The comment Vue renders in place of an `undefined` component has no
3497
+ // public instance: the template rendering it is the one that failed.
3498
+ const source = instance?.$ ?? getCurrentInstance();
3499
+ if (!withinFailedSetup(source, failedSetups))
3500
+ console.warn(`[Vue warn]: ${message}${trace ? `\n${trace}` : ""}`);
3501
+ };
3502
+ app.config.errorHandler = (reason, instance, info) => {
3503
+ if (instance && info === SETUP_FUNCTION_ERROR) {
3504
+ failedSetups.add(instance.$);
3505
+ // From the first failure on only: until then Vue keeps its own output,
3506
+ // whose component trace shows the props. An app that already has a
3507
+ // handler keeps it. A server renders a single page per app; in the
3508
+ // browser the app outlives Inertia visits, so once a page has failed
3509
+ // the handler stays for the session and later warnings print as plain
3510
+ // text (no inspectable objects), still all of them.
3511
+ app.config.warnHandler ??= warnOutsideFailedSetups;
3512
+ }
3513
+ failPage(runtime, reason);
3514
+ };
3348
3515
  app.config.globalProperties.$dms = appContext;
3349
3516
  Reflect.set(app, "$dms", appContext);
3350
3517
  Reflect.set(app, "$i18n", i18n);
@@ -10,6 +10,7 @@ frontend-modules/**/i18n/locales/
10
10
  # CSS hot update on plain navigation.
11
11
  auto-imports.d.ts
12
12
  components.d.ts
13
+ sfc-global-types.generated.d.ts
13
14
  # Build output, which the email bundle watcher rewrites during development:
14
15
  # read as Tailwind sources, each rebuild would push a burst of CSS updates.
15
16
  dist/
@@ -9,11 +9,14 @@ import {
9
9
  configureDmsApp,
10
10
  resolveDmsInertiaPage,
11
11
  } from "./app-runtime";
12
- import { setupFrontendModules } from "./frontend-module";
12
+ import { setupFrontendModules, withPublicOptions } from "./frontend-module";
13
+ import { parsePublicOptions } from "./public-options";
13
14
  import { frontendModules } from "./frontend-modules.generated";
14
15
  import { sameOriginIconProvider } from "./icon-api.mjs";
15
16
 
16
17
  const DEV_STYLE_ATTRIBUTE = "data-dms-dev-style";
18
+ /** Mirrors `PUBLIC_OPTIONS_ELEMENT_ID` in `server/public-options.mjs`. */
19
+ const PUBLIC_OPTIONS_ELEMENT_ID = "dms-public-options";
17
20
 
18
21
  /**
19
22
  * Hand each development stylesheet over to Vite.
@@ -57,6 +60,26 @@ if (import.meta.env.DEV) adoptViteDevStyles();
57
60
  // the generated server answers those queries from the installed collections.
58
61
  addAPIProvider("", sameOriginIconProvider(location.origin));
59
62
 
63
+ /**
64
+ * Shows the URL of the page the server sent, when it rendered a query the page
65
+ * completed in place of the one requested (`renderDocument` in server.mjs).
66
+ * Replaced before Inertia starts: Inertia records a first page whose URL is
67
+ * not the address bar's as a new history entry, and Back would then lead to
68
+ * the URL the page completes again.
69
+ */
70
+ function showServedUrl(url: string): void {
71
+ const served = new URL(url, location.href);
72
+ if (
73
+ served.pathname === location.pathname &&
74
+ served.search !== location.search
75
+ )
76
+ history.replaceState(
77
+ history.state,
78
+ "",
79
+ `${served.pathname}${served.search}${location.hash}`,
80
+ );
81
+ }
82
+
60
83
  /**
61
84
  * Sets the modules up, then starts the app.
62
85
  *
@@ -67,7 +90,13 @@ addAPIProvider("", sameOriginIconProvider(location.origin));
67
90
  * browser requested one by one.
68
91
  */
69
92
  async function startDmsApp(): Promise<void> {
70
- await setupFrontendModules(frontendModules, appConfigs);
93
+ const runtimeOptions = parsePublicOptions(
94
+ document.getElementById(PUBLIC_OPTIONS_ELEMENT_ID)?.textContent,
95
+ );
96
+ await setupFrontendModules(
97
+ withPublicOptions(frontendModules, runtimeOptions),
98
+ appConfigs,
99
+ );
71
100
  // A layer's app config edited during development reaches the page without a
72
101
  // reload. A server render imports the new configs instead.
73
102
  syncAppConfigs(applyLayerAppConfigs);
@@ -75,6 +104,7 @@ async function startDmsApp(): Promise<void> {
75
104
  createInertiaApp({
76
105
  resolve: resolveDmsInertiaPage,
77
106
  async setup({ App, el, props, plugin }) {
107
+ showServedUrl(props.initialPage.url);
78
108
  const root = { render: () => h(App, props) };
79
109
  const app = el.hasAttribute("data-server-rendered")
80
110
  ? createSSRApp(root)
@@ -18,7 +18,9 @@ const NETWORK_ERROR_KEY = "error.network";
18
18
  const DEFAULT_LOCALE = "en";
19
19
  // Shipped with the renderer, since no module is guaranteed to translate the
20
20
  // case; a module overrides them with `error.network.title` and
21
- // `error.network.description` in its own locale files.
21
+ // `error.network.description` in its own locale files. `te()` reads the
22
+ // visitor's locale alone, and the generated catalogs leave these keys out of
23
+ // their English fill, so only that locale's own message replaces them.
22
24
  const NETWORK_ERROR_MESSAGES: Record<
23
25
  string,
24
26
  Record<"title" | "description", string>
@@ -23,9 +23,9 @@
23
23
  "@nuxt/ui": "4.10.0",
24
24
  "@tailwindcss/typography": "^0.5.19",
25
25
  "@unhead/vue": "^2.0.14",
26
- "@vitejs/plugin-vue": "^6.0.0",
26
+ "@vitejs/plugin-vue": "^6.0.6",
27
27
  "@vue/server-renderer": "^3.5.0",
28
- "@vueuse/core": "^14.3.0",
28
+ "@vueuse/core": "14.4.0",
29
29
  "defu": "^6.1.4",
30
30
  "devalue": "^5.9.4",
31
31
  "ofetch": "^1.4.1",
@@ -34,7 +34,7 @@
34
34
  "ufo": "^1.6.1",
35
35
  "unplugin-auto-import": "^20.2.0",
36
36
  "unplugin-vue-components": "^29.0.0",
37
- "vite": "^7.0.0",
37
+ "vite": "^8.3.0",
38
38
  "vue": "^3.5.0",
39
39
  "vue-i18n": "^11.1.12"
40
40
  },
@@ -0,0 +1,24 @@
1
+ diff --git a/dist/index.js b/dist/index.js
2
+ index 772a5e9..d433a57 100644
3
+ --- a/dist/index.js
4
+ +++ b/dist/index.js
5
+ @@ -2047,11 +2047,16 @@ function useColorMode(options = {}) {
6
+ let attributeToChange = null;
7
+ if (attribute === "class") {
8
+ const current = value.split(/\s/g);
9
+ + // Patched by @antelopejs/dms-frontend until vueuse ships the fix: only
10
+ + // the classes `el` lacks or still carries count as a change, so a mode
11
+ + // already applied (by the server render, another instance or the
12
+ + // mount re-run) skips the forced style recalculation below.
13
+ Object.values(modes).flatMap((i) => (i || "").split(/\s/g)).filter(Boolean).forEach((v) => {
14
+ - if (current.includes(v)) classesToAdd.add(v);
15
+ - else classesToRemove.add(v);
16
+ + if (current.includes(v)) {
17
+ + if (!el.classList.contains(v)) classesToAdd.add(v);
18
+ + } else if (el.classList.contains(v)) classesToRemove.add(v);
19
+ });
20
+ - } else attributeToChange = {
21
+ + } else if (el.getAttribute(attribute) !== value) attributeToChange = {
22
+ key: attribute,
23
+ value
24
+ };
@@ -9,3 +9,8 @@ patchedDependencies:
9
9
  # Menu and breadcrumb links active on a sibling route (`/test` on `/test-a`):
10
10
  # nuxt/ui#6803, fixed by nuxt/ui#6809.
11
11
  "@nuxt/ui@4.10.0": patches/@nuxt__ui@4.10.0.patch
12
+ # useColorMode re-applies a color mode `<html>` already carries, forcing a
13
+ # full style recalculation on every call (6 on the dashboard home, 216 ms of
14
+ # main thread at 4x CPU): the guard of vueuse#4001 compares the modes with the
15
+ # requested value instead of the element. vueuse 15.0.0 still ships it.
16
+ "@vueuse/core@14.4.0": patches/@vueuse__core@14.4.0.patch
@@ -23,12 +23,7 @@ await build({
23
23
  build: {
24
24
  write: false,
25
25
  emptyOutDir: false,
26
- rollupOptions: {
27
- input: ENTRY,
28
- // The entry is empty on purpose.
29
- onwarn: (warning, warn) => {
30
- if (warning.code !== "EMPTY_BUNDLE") warn(warning);
31
- },
32
- },
26
+ // The entry is empty on purpose; Rolldown does not warn about it.
27
+ rolldownOptions: { input: ENTRY },
33
28
  },
34
29
  });
@@ -0,0 +1,19 @@
1
+ /** Public options by module id, as `ajs dms start` computes them. */
2
+ export type DmsPublicOptionsById = Record<string, Record<string, unknown>>;
3
+
4
+ /** `DMS_PUBLIC_OPTIONS` as options by module id, undefined when unset. */
5
+ export function parsePublicOptions(
6
+ raw: string | null | undefined,
7
+ ): DmsPublicOptionsById | undefined {
8
+ if (!raw) return undefined;
9
+ try {
10
+ const parsed: unknown = JSON.parse(raw);
11
+ return typeof parsed === "object" &&
12
+ parsed !== null &&
13
+ !Array.isArray(parsed)
14
+ ? (parsed as DmsPublicOptionsById)
15
+ : undefined;
16
+ } catch {
17
+ return undefined;
18
+ }
19
+ }